| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // The release gate: all eight embedded official themes must parse through the |
| 14 | // Theme Pack V1 validator with unique ids/names, valid images and budgets. |
| 15 | func TestOfficialThemesAllValid(t *testing.T) { |
| 16 | resetOfficialRegistryForTest() |
| 17 | if err := validateOfficialThemes(); err != nil { |
| 18 | t.Fatalf("official registry invalid: %v", err) |
| 19 | } |
| 20 | themes := officialThemes() |
| 21 | if len(themes) != officialExpectedCount { |
| 22 | t.Fatalf("expected %d official themes, got %d", officialExpectedCount, len(themes)) |
| 23 | } |
| 24 | ids := map[string]bool{} |
| 25 | names := map[string]bool{} |
| 26 | var total int64 |
| 27 | for _, ot := range themes { |
| 28 | m := ot.manifest |
| 29 | if ids[m.ID] { |
| 30 | t.Fatalf("duplicate official id %q", m.ID) |
| 31 | } |
| 32 | ids[m.ID] = true |
| 33 | if names[m.Name] { |
| 34 | t.Fatalf("duplicate official name %q", m.Name) |
| 35 | } |
| 36 | names[m.Name] = true |
| 37 | if !strings.HasPrefix(m.ID, "official-") { |
| 38 | t.Fatalf("official id %q must carry the official- prefix", m.ID) |
| 39 | } |
| 40 | if isBuiltinThemeID(m.ID) { |
| 41 | t.Fatalf("official id %q collides with a base style", m.ID) |
| 42 | } |
| 43 | if m.Background == nil || m.Background.Image != "background.webp" { |
| 44 | t.Fatalf("%s: background must be background.webp", m.ID) |
| 45 | } |
| 46 | if m.Background.PaneOpacity == nil || *m.Background.PaneOpacity != 0.5 { |
| 47 | t.Fatalf("%s: paneOpacity must be 0.5, got %v", m.ID, m.Background.PaneOpacity) |
| 48 | } |
| 49 | if m.Background.SafeArea != "left" { |
| 50 | t.Fatalf("%s: safeArea must be left, got %q", m.ID, m.Background.SafeArea) |
| 51 | } |
| 52 | if m.Recipes.Density != "comfortable" { |
| 53 | t.Fatalf("%s: density must be comfortable", m.ID) |
| 54 | } |
| 55 | if m.Author != "Reasonix Contributors" || m.License != "MIT" { |
| 56 | t.Fatalf("%s: author/license = %q/%q", m.ID, m.Author, m.License) |
| 57 | } |
| 58 | if ot.bgSize > officialMaxBackground { |
| 59 | t.Fatalf("%s: background %d bytes exceeds budget", m.ID, ot.bgSize) |
| 60 | } |
| 61 | if len(ot.bgDigest) != 16 || len(ot.previewDigest) != 16 { |
| 62 | t.Fatalf("%s: digests not computed", m.ID) |
| 63 | } |
| 64 | total += ot.bgSize |
| 65 | // Every official manifest must ship full light+dark surface coverage and |
| 66 | // zero contrast warnings. |
| 67 | for _, mode := range []string{"light", "dark"} { |
| 68 | tk := m.Tokens.Light |
| 69 | if mode == "dark" { |
| 70 | tk = m.Tokens.Dark |
| 71 | } |
| 72 | for _, key := range []string{"bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles", "border", "borderSoft", "fg", "fgDim", "fgFaint", "accent", "accentFg"} { |
| 73 | if strings.TrimSpace(tk[key]) == "" { |
| 74 | t.Fatalf("%s %s: missing token %q", m.ID, mode, key) |
| 75 | } |
| 76 | } |
| 77 | // ok/warn/err stay inherited from the base style. |
| 78 | for _, key := range []string{"ok", "warn", "err"} { |
| 79 | if _, set := tk[key]; set { |
| 80 | t.Fatalf("%s %s: %s must inherit the base style", m.ID, mode, key) |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | if warns := computeContrastWarnings(&m); len(warns) != 0 { |
| 85 | t.Fatalf("%s: contrast warnings: %v", m.ID, warns) |
| 86 | } |
| 87 | } |
| 88 | if total > officialMaxTotalBytes { |
| 89 | t.Fatalf("official backgrounds total %d bytes exceeds %d", total, officialMaxTotalBytes) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestOfficialImagesDimensionsAndBudgets(t *testing.T) { |
| 94 | for _, ot := range officialThemes() { |
| 95 | bg, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/background.webp") |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if err := validateOfficialImage(bg, "background.webp", officialBackgroundWidth, officialBackgroundHeight, officialMaxBackground); err != nil { |
| 100 | t.Fatalf("%s background: %v", ot.manifest.ID, err) |
| 101 | } |
| 102 | pv, err := officialThemesFS.ReadFile(officialThemeDirName + "/" + ot.manifest.ID + "/preview.webp") |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if err := validateOfficialImage(pv, "preview.webp", officialPreviewWidth, officialPreviewHeight, officialMaxPreview); err != nil { |
| 107 | t.Fatalf("%s preview: %v", ot.manifest.ID, err) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Fail-closed: a broken entry never makes it into the registry. |
| 113 | func TestOfficialFailClosedSkipsInvalid(t *testing.T) { |
| 114 | if _, err := loadOfficialTheme("does-not-exist"); err == nil { |
| 115 | t.Fatal("expected error for missing official theme") |
| 116 | } |
| 117 | if findOfficialTheme("does-not-exist") != nil { |
| 118 | t.Fatal("missing theme must not enter the registry") |
| 119 | } |
| 120 | if isOfficialThemeID("graphite") { |
| 121 | t.Fatal("base styles are not official themes") |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestOfficialReservedIDsRefused(t *testing.T) { |
| 126 | home := t.TempDir() |
| 127 | t.Setenv("REASONIX_HOME", home) |
| 128 | app := NewApp() |
| 129 | |
| 130 | officialID := officialThemes()[0].manifest.ID |
| 131 | for _, id := range []string{"graphite", "aurora", officialID} { |
| 132 | if !isReservedThemeID(id) { |
| 133 | t.Fatalf("%s must be reserved", id) |
| 134 | } |
| 135 | if err := app.DeleteThemePack(id); err == nil { |
| 136 | t.Fatalf("delete %s must fail", id) |
| 137 | } |
| 138 | if _, err := app.SaveThemePack(ThemeSaveInput{ID: id, Name: "Hijack", BaseStyle: "graphite"}); err == nil { |
| 139 | t.Fatalf("save %s must fail", id) |
| 140 | } |
| 141 | if _, err := app.CopyThemePack("graphite", id, "Hijack"); err == nil { |
| 142 | t.Fatalf("copy onto reserved id %s must fail", id) |
| 143 | } |
| 144 | if _, err := app.ExportThemePack(id, filepath.Join(t.TempDir(), "out.reasonix-theme")); err == nil { |
| 145 | t.Fatalf("export %s must fail", id) |
| 146 | } |
| 147 | if _, _, err := importThemePackZIPBytesForID(id); err == nil { |
| 148 | t.Fatalf("import over reserved id %s must fail", id) |
| 149 | } |
| 150 | } |
| 151 | // Overwrite path must refuse reserved ids too. |
| 152 | if err := publishThemeDir(officialID, t.TempDir(), true); err == nil { |
| 153 | t.Fatal("publish over official id must fail") |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // importThemePackZIPBytesForID builds an in-memory package for a reserved id. |
| 158 | func importThemePackZIPBytesForID(id string) (*ThemePackManifest, string, error) { |
| 159 | raw := fmt.Sprintf(`{"schemaVersion":1,"id":%q,"name":"Hijack","baseStyle":"graphite"}`, id) |
| 160 | tmp := filepath.Join(os.TempDir(), "hijack-"+id+".reasonix-theme") |
| 161 | if err := writeThemeZip(tmp, &ThemePackManifest{SchemaVersion: 1, ID: id, Name: "Hijack", BaseStyle: "graphite"}, nil); err != nil { |
| 162 | return nil, "", err |
| 163 | } |
| 164 | defer os.Remove(tmp) |
| 165 | _ = raw |
| 166 | return importThemePackZIP(tmp) |
| 167 | } |
| 168 | |
| 169 | func TestOfficialListOrderAndKinds(t *testing.T) { |
| 170 | home := t.TempDir() |
| 171 | t.Setenv("REASONIX_HOME", home) |
| 172 | app := NewApp() |
| 173 | |
| 174 | // Two user themes. |
| 175 | for _, id := range []string{"user-a", "user-b"} { |
| 176 | m := &ThemePackManifest{SchemaVersion: 1, ID: id, Name: id, BaseStyle: "slate", Recipes: defaultThemePackRecipes()} |
| 177 | staging, err := writeThemeStaging(m, "", nil) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | defer os.RemoveAll(staging) |
| 182 | if err := publishThemeDir(id, staging, false); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | list, err := app.ListThemePacks() |
| 188 | if err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | base, official, user := 0, 0, 0 |
| 192 | seenOfficial := 0 |
| 193 | for i, p := range list { |
| 194 | switch p.Kind { |
| 195 | case themeKindBase: |
| 196 | base++ |
| 197 | if !p.Builtin { |
| 198 | t.Fatal("base must keep builtin=true") |
| 199 | } |
| 200 | case themeKindOfficial: |
| 201 | official++ |
| 202 | seenOfficial = i |
| 203 | if !p.Builtin || !p.HasBackground || p.BackgroundURL == "" || p.PreviewURL == "" { |
| 204 | t.Fatalf("official view incomplete: %+v", p) |
| 205 | } |
| 206 | if p.NameKey == "" || p.DescriptionKey == "" { |
| 207 | t.Fatalf("official i18n keys missing: %+v", p) |
| 208 | } |
| 209 | case themeKindUser: |
| 210 | user++ |
| 211 | if p.Builtin { |
| 212 | t.Fatal("user theme must not be builtin") |
| 213 | } |
| 214 | default: |
| 215 | t.Fatalf("unknown kind %q", p.Kind) |
| 216 | } |
| 217 | } |
| 218 | if base != 6 || official != officialExpectedCount || user < 2 { |
| 219 | t.Fatalf("list = %d base + %d official + %d user", base, official, user) |
| 220 | } |
| 221 | haveA, haveB := false, false |
| 222 | for _, p := range list { |
| 223 | if p.ID == "user-a" { |
| 224 | haveA = true |
| 225 | } |
| 226 | if p.ID == "user-b" { |
| 227 | haveB = true |
| 228 | } |
| 229 | } |
| 230 | if !haveA || !haveB { |
| 231 | t.Fatal("expected user-a and user-b in list") |
| 232 | } |
| 233 | // Order: base first, then official, then user. |
| 234 | if list[0].Kind != themeKindBase || list[6].Kind != themeKindOfficial || list[len(list)-1].Kind != themeKindUser { |
| 235 | t.Fatal("list order must be base, official, user") |
| 236 | } |
| 237 | _ = seenOfficial |
| 238 | } |
| 239 | |
| 240 | func TestOfficialActivateRestoreReset(t *testing.T) { |
| 241 | home := t.TempDir() |
| 242 | t.Setenv("REASONIX_HOME", home) |
| 243 | app := NewApp() |
| 244 | |
| 245 | id := officialThemes()[1].manifest.ID |
| 246 | if err := app.ActivateThemePack(id); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | active, err := app.GetActiveThemePack() |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if active.ActiveThemeID != id || active.Pack == nil || active.Pack.Kind != themeKindOfficial { |
| 254 | t.Fatalf("active = %+v", active) |
| 255 | } |
| 256 | if active.Pack.BackgroundURL == "" || active.Pack.PreviewURL == "" { |
| 257 | t.Fatalf("official pack must carry background + preview URLs: %+v", active.Pack) |
| 258 | } |
| 259 | // Restart recovery: a fresh App reads the same state file. |
| 260 | app2 := NewApp() |
| 261 | active2, err := app2.GetActiveThemePack() |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if active2.ActiveThemeID != id { |
| 266 | t.Fatalf("restart did not restore official theme: %+v", active2) |
| 267 | } |
| 268 | // Reset returns to the Graphite path. |
| 269 | if err := app2.ResetThemePack(); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | active3, _ := app2.GetActiveThemePack() |
| 273 | if active3.ActiveThemeID != "" || active3.Pack != nil { |
| 274 | t.Fatalf("reset failed: %+v", active3) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestOfficialCopyBecomesEditableUserTheme(t *testing.T) { |
| 279 | home := t.TempDir() |
| 280 | t.Setenv("REASONIX_HOME", home) |
| 281 | app := NewApp() |
| 282 | |
| 283 | src := officialThemes()[2].manifest.ID |
| 284 | created, err := app.CopyThemePack(src, "my-fortune", "My Fortune") |
| 285 | if err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | if created.Kind != themeKindUser || created.ID != "my-fortune" { |
| 289 | t.Fatalf("copy view = %+v", created) |
| 290 | } |
| 291 | // Background bytes were embedded -> private copy on disk. |
| 292 | m, err := loadUserThemeManifest("my-fortune") |
| 293 | if err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | if m.Background == nil || m.Background.Image == "" { |
| 297 | t.Fatal("copy must keep the background") |
| 298 | } |
| 299 | img, err := resolveThemeImageAbs("my-fortune", m.Background.Image) |
| 300 | if err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | if err := validateThemeImageFile(img); err != nil { |
| 304 | t.Fatalf("copied background invalid: %v", err) |
| 305 | } |
| 306 | // The copy is an ordinary user theme: editable and exportable. |
| 307 | if _, err := app.SaveThemePack(ThemeSaveInput{ID: "my-fortune", Name: "Renamed", BaseStyle: m.BaseStyle, Replace: true}); err != nil { |
| 308 | t.Fatalf("edit copy: %v", err) |
| 309 | } |
| 310 | if _, err := app.ExportThemePack("my-fortune", filepath.Join(t.TempDir(), "copy")); err != nil { |
| 311 | t.Fatalf("export copy: %v", err) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | func TestOfficialAssetRoute(t *testing.T) { |
| 316 | home := t.TempDir() |
| 317 | t.Setenv("REASONIX_HOME", home) |
| 318 | app := NewApp() |
| 319 | |
| 320 | ot := officialThemes()[0] |
| 321 | id := ot.manifest.ID |
| 322 | bgURL := officialAssetURL(id, "background.webp") |
| 323 | pvURL := officialAssetURL(id, officialPreviewName) |
| 324 | if bgURL == "" || pvURL == "" { |
| 325 | t.Fatal("official URLs empty") |
| 326 | } |
| 327 | if officialAssetURL(id, "theme.json") != "" || officialAssetURL(id, "../theme.json") != "" { |
| 328 | t.Fatal("undeclared assets must not resolve") |
| 329 | } |
| 330 | |
| 331 | mw := app.themeAssetMiddleware() |
| 332 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 333 | w.WriteHeader(http.StatusTeapot) |
| 334 | })) |
| 335 | |
| 336 | // GET background |
| 337 | rec := httptest.NewRecorder() |
| 338 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, bgURL, nil)) |
| 339 | if rec.Code != http.StatusOK { |
| 340 | t.Fatalf("GET bg status %d", rec.Code) |
| 341 | } |
| 342 | if ct := rec.Header().Get("Content-Type"); ct != "image/webp" { |
| 343 | t.Fatalf("bg content-type %q", ct) |
| 344 | } |
| 345 | if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { |
| 346 | t.Fatalf("official cache-control %q", cc) |
| 347 | } |
| 348 | if rec.Body.Len() != int(ot.bgSize) { |
| 349 | t.Fatalf("bg bytes %d != %d", rec.Body.Len(), ot.bgSize) |
| 350 | } |
| 351 | |
| 352 | // HEAD preview |
| 353 | rec = httptest.NewRecorder() |
| 354 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, pvURL, nil)) |
| 355 | if rec.Code != http.StatusOK { |
| 356 | t.Fatalf("HEAD preview status %d", rec.Code) |
| 357 | } |
| 358 | |
| 359 | // Wrong digest |
| 360 | rec = httptest.NewRecorder() |
| 361 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/deadbeefdeadbeef/background.webp", nil)) |
| 362 | if rec.Code != http.StatusNotFound { |
| 363 | t.Fatalf("wrong digest status %d", rec.Code) |
| 364 | } |
| 365 | |
| 366 | // Undeclared filename |
| 367 | rec = httptest.NewRecorder() |
| 368 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/"+ot.bgDigest+"/theme.json", nil)) |
| 369 | if rec.Code != http.StatusNotFound { |
| 370 | t.Fatalf("undeclared file status %d", rec.Code) |
| 371 | } |
| 372 | |
| 373 | // Path traversal stays rejected |
| 374 | rec = httptest.NewRecorder() |
| 375 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+id+"/x/../background.webp", nil)) |
| 376 | if rec.Code != http.StatusNotFound { |
| 377 | t.Fatalf("traversal status %d", rec.Code) |
| 378 | } |
| 379 | |
| 380 | // Wrong method |
| 381 | rec = httptest.NewRecorder() |
| 382 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, bgURL, nil)) |
| 383 | if rec.Code != http.StatusMethodNotAllowed { |
| 384 | t.Fatalf("POST status %d", rec.Code) |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // v1.20+: REASONIX_SAFE_MODE no longer restricts official theme packs. |
| 389 | func TestOfficialThemesAvailableDespiteSafeModeEnv(t *testing.T) { |
| 390 | home := t.TempDir() |
| 391 | t.Setenv("REASONIX_HOME", home) |
| 392 | t.Setenv("REASONIX_SAFE_MODE", "1") |
| 393 | app := NewApp() |
| 394 | |
| 395 | list, err := app.ListThemePacks() |
| 396 | if err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | if len(list) < 6 { |
| 400 | t.Fatalf("official themes must remain available, got %d packs", len(list)) |
| 401 | } |
| 402 | // Activation of an official pack must not be refused solely by Safe Mode env. |
| 403 | if err := app.ActivateThemePack(officialThemes()[0].manifest.ID); err != nil { |
| 404 | t.Fatalf("official activation: %v", err) |
| 405 | } |
| 406 | } |
| 407 |