| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "image" |
| 8 | "image/color" |
| 9 | "image/png" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | |
| 17 | "reasonix/internal/pluginpkg" |
| 18 | ) |
| 19 | |
| 20 | // Plugin theme contract tests (Stage 4b): discovery from enabled installed |
| 21 | // plugins, invalid files skipped with warnings, plugin:<plugin>:<theme> id |
| 22 | // activation/persistence, the fallback-preserve contract across plugin |
| 23 | // removal + restore on reinstall, read-only guards, and legacy state files. |
| 24 | |
| 25 | type pluginThemeFixture struct { |
| 26 | fileName string // ZIP file name under themes/ |
| 27 | manifest *ThemePackManifest // nil → garbage bytes (invalid pack) |
| 28 | withImage bool // embed a small PNG scene image |
| 29 | } |
| 30 | |
| 31 | func testPluginThemeManifest(id, name string) *ThemePackManifest { |
| 32 | return &ThemePackManifest{ |
| 33 | SchemaVersion: themePackSchemaVersion, |
| 34 | ID: id, |
| 35 | Name: name, |
| 36 | Author: "Fixture", |
| 37 | BaseStyle: "graphite", |
| 38 | Tokens: ThemePackTokens{ |
| 39 | Dark: map[string]string{"accent": "#ff6a3d"}, |
| 40 | }, |
| 41 | Recipes: defaultThemePackRecipes(), |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func testPNGBytes(t *testing.T) []byte { |
| 46 | t.Helper() |
| 47 | img := image.NewRGBA(image.Rect(0, 0, 8, 8)) |
| 48 | for y := 0; y < 8; y++ { |
| 49 | for x := 0; x < 8; x++ { |
| 50 | img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 30), B: 40, A: 255}) |
| 51 | } |
| 52 | } |
| 53 | var buf bytes.Buffer |
| 54 | if err := png.Encode(&buf, img); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | return buf.Bytes() |
| 58 | } |
| 59 | |
| 60 | // installPluginThemeFixture writes a Manifest v1 plugin root with |
| 61 | // contributes.themes globbing themes/*.reasonix-theme, plus the |
| 62 | // plugin-packages.json entry pointing at it. |
| 63 | func installPluginThemeFixture(t *testing.T, home, pluginName string, enabled bool, themes []pluginThemeFixture) string { |
| 64 | t.Helper() |
| 65 | root := filepath.Join(home, "plugin-src", pluginName) |
| 66 | themesDir := filepath.Join(root, "themes") |
| 67 | if err := os.MkdirAll(themesDir, 0o755); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | for _, fx := range themes { |
| 71 | dest := filepath.Join(themesDir, fx.fileName) |
| 72 | if fx.manifest == nil { |
| 73 | if err := os.WriteFile(dest, []byte("this is not a zip"), 0o644); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | continue |
| 77 | } |
| 78 | m := *fx.manifest |
| 79 | var img []byte |
| 80 | if fx.withImage { |
| 81 | img = testPNGBytes(t) |
| 82 | if m.Background == nil { |
| 83 | bg := defaultThemePackBackground() |
| 84 | m.Background = &bg |
| 85 | } |
| 86 | m.Background.Image = "background.png" |
| 87 | } |
| 88 | if err := writeThemeZip(dest, &m, img); err != nil { |
| 89 | t.Fatalf("write fixture theme %s: %v", fx.fileName, err) |
| 90 | } |
| 91 | } |
| 92 | manifestJSON := fmt.Sprintf( |
| 93 | `{"apiVersion":%q,"name":%q,"version":"1.0.0","contributes":{"themes":["themes/*.reasonix-theme"]}}`, |
| 94 | pluginpkg.ManifestAPIVersionV1, pluginName, |
| 95 | ) |
| 96 | if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(manifestJSON), 0o644); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{ |
| 100 | Name: pluginName, |
| 101 | Root: root, |
| 102 | Version: "1.0.0", |
| 103 | ManifestKind: "reasonix", |
| 104 | Enabled: enabled, |
| 105 | }); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | return root |
| 109 | } |
| 110 | |
| 111 | func findThemePackView(list []ThemePackView, id string) *ThemePackView { |
| 112 | for i := range list { |
| 113 | if list[i].ID == id { |
| 114 | return &list[i] |
| 115 | } |
| 116 | } |
| 117 | return nil |
| 118 | } |
| 119 | |
| 120 | func readThemeStateRaw(t *testing.T) string { |
| 121 | t.Helper() |
| 122 | raw, err := os.ReadFile(themeStatePath()) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | return string(raw) |
| 127 | } |
| 128 | |
| 129 | func TestParsePluginThemeID(t *testing.T) { |
| 130 | cases := []struct { |
| 131 | id string |
| 132 | ok bool |
| 133 | pluginName string |
| 134 | themeID string |
| 135 | }{ |
| 136 | {"plugin:themery:neon-dusk", true, "themery", "neon-dusk"}, |
| 137 | {"plugin:My_Plugin.2:x", true, "My_Plugin.2", "x"}, |
| 138 | {"plugin:themery:neon-dusk:extra", false, "", ""}, // theme ids carry no colons |
| 139 | {"plugin:themery", false, "", ""}, |
| 140 | {"plugin:themery:", false, "", ""}, |
| 141 | {"plugin::neon-dusk", false, "", ""}, |
| 142 | {"plugin:themery:Neon", false, "", ""}, // themePackIDRe still governs the inner id |
| 143 | {"neon-dusk", false, "", ""}, |
| 144 | {"official-rose-dawn", false, "", ""}, |
| 145 | {"", false, "", ""}, |
| 146 | } |
| 147 | for _, tc := range cases { |
| 148 | pluginName, themeID, ok := parsePluginThemeID(tc.id) |
| 149 | if ok != tc.ok || pluginName != tc.pluginName || themeID != tc.themeID { |
| 150 | t.Fatalf("parsePluginThemeID(%q) = %q, %q, %v; want %q, %q, %v", |
| 151 | tc.id, pluginName, themeID, ok, tc.pluginName, tc.themeID, tc.ok) |
| 152 | } |
| 153 | } |
| 154 | // isPluginThemeID is prefix-only on purpose (fallback contract seam). |
| 155 | if !isPluginThemeID("plugin:garbage::") { |
| 156 | t.Fatal("isPluginThemeID must be prefix-only") |
| 157 | } |
| 158 | if isPluginThemeID("neon-dusk") || isPluginThemeID("") { |
| 159 | t.Fatal("isPluginThemeID false positive") |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestPluginThemeDiscoveryAndList(t *testing.T) { |
| 164 | home := t.TempDir() |
| 165 | t.Setenv("REASONIX_HOME", home) |
| 166 | pluginRoot := installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{ |
| 167 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk"), withImage: true}, |
| 168 | }) |
| 169 | app := NewApp() |
| 170 | |
| 171 | list, err := app.ListThemePacks() |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | view := findThemePackView(list, "plugin:themery:neon-dusk") |
| 176 | if view == nil { |
| 177 | t.Fatalf("plugin theme not listed: %+v", list) |
| 178 | } |
| 179 | if view.Kind != themeKindPlugin { |
| 180 | t.Fatalf("kind = %q, want plugin", view.Kind) |
| 181 | } |
| 182 | if view.PluginName != "themery" { |
| 183 | t.Fatalf("pluginName = %q, want themery", view.PluginName) |
| 184 | } |
| 185 | if view.Builtin { |
| 186 | t.Fatal("plugin theme must not be builtin") |
| 187 | } |
| 188 | if view.Name != "Neon Dusk" || view.BaseStyle != "graphite" { |
| 189 | t.Fatalf("view = %+v", view) |
| 190 | } |
| 191 | if !view.HasBackground || view.BackgroundURL == "" { |
| 192 | t.Fatalf("plugin theme must expose its background: %+v", view) |
| 193 | } |
| 194 | if !strings.HasPrefix(view.BackgroundURL, themeAssetURLPrefix+"plugin:themery:neon-dusk/") { |
| 195 | t.Fatalf("background URL not content-addressed under the plugin id: %q", view.BackgroundURL) |
| 196 | } |
| 197 | // Plugin themes come after base/official/user. |
| 198 | if list[len(list)-1].ID != "plugin:themery:neon-dusk" { |
| 199 | t.Fatalf("plugin themes must sort last: %q", list[len(list)-1].ID) |
| 200 | } |
| 201 | // Nothing is copied into the user theme library (the library dir is shared |
| 202 | // across this test package, so assert on the plugin theme's own id). |
| 203 | if userThemeExists("neon-dusk") { |
| 204 | t.Fatal("plugin themes must never be copied into the user library") |
| 205 | } |
| 206 | // The pack stays inside the plugin root. |
| 207 | if _, err := os.Stat(filepath.Join(pluginRoot, "themes", "neon.reasonix-theme")); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestPluginThemeInvalidSkippedWithWarning(t *testing.T) { |
| 213 | home := t.TempDir() |
| 214 | t.Setenv("REASONIX_HOME", home) |
| 215 | installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{ |
| 216 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")}, |
| 217 | {fileName: "broken.reasonix-theme", manifest: nil}, // invalid ZIP |
| 218 | }) |
| 219 | app := NewApp() |
| 220 | |
| 221 | list, err := app.ListThemePacks() |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if findThemePackView(list, "plugin:themery:broken") != nil { |
| 226 | t.Fatal("invalid plugin theme must be skipped") |
| 227 | } |
| 228 | view := findThemePackView(list, "plugin:themery:neon-dusk") |
| 229 | if view == nil { |
| 230 | t.Fatal("valid plugin theme missing") |
| 231 | } |
| 232 | if len(view.Warnings) == 0 || !strings.Contains(view.Warnings[0], "broken.reasonix-theme") { |
| 233 | t.Fatalf("skipped file must surface a warning on the plugin's views: %+v", view.Warnings) |
| 234 | } |
| 235 | |
| 236 | exp, err := app.GetThemeExperience() |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | found := false |
| 241 | for _, w := range exp.Warnings { |
| 242 | if strings.Contains(w, "broken.reasonix-theme") { |
| 243 | found = true |
| 244 | } |
| 245 | } |
| 246 | if !found { |
| 247 | t.Fatalf("experience warnings must aggregate the skipped file: %v", exp.Warnings) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | func TestPluginThemeDisabledNotListed(t *testing.T) { |
| 252 | home := t.TempDir() |
| 253 | t.Setenv("REASONIX_HOME", home) |
| 254 | installPluginThemeFixture(t, home, "themery", false, []pluginThemeFixture{ |
| 255 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")}, |
| 256 | }) |
| 257 | app := NewApp() |
| 258 | |
| 259 | list, err := app.ListThemePacks() |
| 260 | if err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | if findThemePackView(list, "plugin:themery:neon-dusk") != nil { |
| 264 | t.Fatal("disabled plugin themes must not be listed") |
| 265 | } |
| 266 | if err := app.ActivateThemePack("plugin:themery:neon-dusk"); err == nil { |
| 267 | t.Fatal("activating a disabled plugin's theme must fail") |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestPluginThemeActivatePersistFallbackRestore(t *testing.T) { |
| 272 | home := t.TempDir() |
| 273 | t.Setenv("REASONIX_HOME", home) |
| 274 | themes := []pluginThemeFixture{ |
| 275 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")}, |
| 276 | } |
| 277 | installPluginThemeFixture(t, home, "themery", true, themes) |
| 278 | app := NewApp() |
| 279 | |
| 280 | const activeID = "plugin:themery:neon-dusk" |
| 281 | if err := app.ActivateThemePack(activeID); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | active, err := app.GetActiveThemePack() |
| 285 | if err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | if active.ActiveThemeID != activeID || active.Pack == nil || active.Pack.Kind != themeKindPlugin { |
| 289 | t.Fatalf("active = %+v", active) |
| 290 | } |
| 291 | if !strings.Contains(readThemeStateRaw(t), activeID) { |
| 292 | t.Fatal("full plugin theme id must persist in desktop-theme-state.json") |
| 293 | } |
| 294 | |
| 295 | // Plugin disappears (uninstalled): rendering falls back to the base style, |
| 296 | // but the pointer is PRESERVED — the state file is not rewritten. |
| 297 | if _, ok, err := pluginpkg.Remove(home, "themery"); err != nil || !ok { |
| 298 | t.Fatalf("remove plugin: ok=%v err=%v", ok, err) |
| 299 | } |
| 300 | before := readThemeStateRaw(t) |
| 301 | app2 := NewApp() |
| 302 | exp, err := app2.GetThemeExperience() |
| 303 | if err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if exp.ActiveThemeID != "" || exp.ActivePack != nil { |
| 307 | t.Fatalf("missing plugin must fall back to base style: %+v", exp) |
| 308 | } |
| 309 | if exp.EffectiveStyle != exp.BaseStyle { |
| 310 | t.Fatalf("effective style must be the configured base style: %+v", exp) |
| 311 | } |
| 312 | if after := readThemeStateRaw(t); after != before { |
| 313 | t.Fatalf("state file must not be rewritten while the plugin is missing:\n%s\n!=\n%s", before, after) |
| 314 | } |
| 315 | active2, err := app2.GetActiveThemePack() |
| 316 | if err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | if active2.ActiveThemeID != "" || active2.Pack != nil { |
| 320 | t.Fatalf("missing plugin must render no pack: %+v", active2) |
| 321 | } |
| 322 | if after := readThemeStateRaw(t); after != before { |
| 323 | t.Fatal("GetActiveThemePack must not clear a plugin pointer") |
| 324 | } |
| 325 | |
| 326 | // Reinstalling the same plugin id restores the theme. |
| 327 | installPluginThemeFixture(t, home, "themery", true, themes) |
| 328 | app3 := NewApp() |
| 329 | active3, err := app3.GetActiveThemePack() |
| 330 | if err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | if active3.ActiveThemeID != activeID || active3.Pack == nil || active3.Pack.Kind != themeKindPlugin { |
| 334 | t.Fatalf("reinstall must restore the plugin theme: %+v", active3) |
| 335 | } |
| 336 | |
| 337 | // Disabled (not uninstalled) behaves the same: fallback + preserve. |
| 338 | if err := pluginpkg.SetEnabled(home, "themery", false); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | before = readThemeStateRaw(t) |
| 342 | exp, err = NewApp().GetThemeExperience() |
| 343 | if err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | if exp.ActiveThemeID != "" || exp.ActivePack != nil { |
| 347 | t.Fatalf("disabled plugin must fall back to base style: %+v", exp) |
| 348 | } |
| 349 | if after := readThemeStateRaw(t); after != before { |
| 350 | t.Fatal("disabling the plugin must not rewrite the theme state") |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | func TestPluginThemeReadOnlyGuards(t *testing.T) { |
| 355 | home := t.TempDir() |
| 356 | t.Setenv("REASONIX_HOME", home) |
| 357 | pluginRoot := installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{ |
| 358 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk")}, |
| 359 | }) |
| 360 | app := NewApp() |
| 361 | |
| 362 | const id = "plugin:themery:neon-dusk" |
| 363 | if _, err := app.SaveThemePack(ThemeSaveInput{ID: id, Name: "Hijack", BaseStyle: "graphite"}); err == nil { |
| 364 | t.Fatal("SaveThemePack must reject plugin theme ids") |
| 365 | } |
| 366 | if err := app.DeleteThemePack(id); err == nil { |
| 367 | t.Fatal("DeleteThemePack must reject plugin theme ids") |
| 368 | } |
| 369 | if _, err := app.ExportThemePack(id, filepath.Join(home, "out")); err == nil { |
| 370 | t.Fatal("ExportThemePack must reject plugin theme ids") |
| 371 | } |
| 372 | if _, err := app.CopyThemePack(id, "neon-copy", "Neon Copy"); err == nil { |
| 373 | t.Fatal("CopyThemePack must reject plugin theme sources") |
| 374 | } |
| 375 | // The plugin ZIP is untouched and no user library entry appeared. |
| 376 | if _, err := os.Stat(filepath.Join(pluginRoot, "themes", "neon.reasonix-theme")); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | if userThemeExists("neon-dusk") || userThemeExists("neon-copy") { |
| 380 | t.Fatal("read-only guards must not write into the user theme library") |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestLegacyStateWithPluginIDPreserved(t *testing.T) { |
| 385 | home := t.TempDir() |
| 386 | t.Setenv("REASONIX_HOME", home) |
| 387 | |
| 388 | // State written by a build that already knew plugin themes; no plugin is |
| 389 | // installed here. New code must read it, fall back, and preserve it. |
| 390 | if err := os.MkdirAll(filepath.Dir(themeStatePath()), 0o755); err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | raw := []byte(`{"schemaVersion":2,"activeThemeId":"plugin:ghost:pastel"}`) |
| 394 | if err := os.WriteFile(themeStatePath(), raw, 0o644); err != nil { |
| 395 | t.Fatal(err) |
| 396 | } |
| 397 | |
| 398 | st := loadThemeDesktopState() |
| 399 | if st.ActiveThemeID != "plugin:ghost:pastel" { |
| 400 | t.Fatalf("legacy plugin pointer must decode: %+v", st) |
| 401 | } |
| 402 | app := NewApp() |
| 403 | exp, err := app.GetThemeExperience() |
| 404 | if err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | if exp.ActiveThemeID != "" || exp.ActivePack != nil { |
| 408 | t.Fatalf("unknown plugin pointer must fall back: %+v", exp) |
| 409 | } |
| 410 | after, err := os.ReadFile(themeStatePath()) |
| 411 | if err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | if !bytes.Equal(after, raw) { |
| 415 | t.Fatalf("legacy plugin pointer must be preserved byte-for-byte:\n%s\n!=\n%s", raw, after) |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | func TestUnknownNonPluginActiveIDStillCleared(t *testing.T) { |
| 420 | home := t.TempDir() |
| 421 | t.Setenv("REASONIX_HOME", home) |
| 422 | |
| 423 | // The pre-plugin erase behavior is unchanged for official/user ids that no |
| 424 | // longer resolve: clear the pointer and save. |
| 425 | if err := os.MkdirAll(filepath.Dir(themeStatePath()), 0o755); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | if err := os.WriteFile(themeStatePath(), []byte(`{"schemaVersion":2,"activeThemeId":"ghost-theme"}`), 0o644); err != nil { |
| 429 | t.Fatal(err) |
| 430 | } |
| 431 | app := NewApp() |
| 432 | exp, err := app.GetThemeExperience() |
| 433 | if err != nil { |
| 434 | t.Fatal(err) |
| 435 | } |
| 436 | if exp.ActiveThemeID != "" { |
| 437 | t.Fatalf("unknown non-plugin id must clear: %+v", exp) |
| 438 | } |
| 439 | raw, err := os.ReadFile(themeStatePath()) |
| 440 | if err != nil { |
| 441 | t.Fatal(err) |
| 442 | } |
| 443 | var saved ThemeDesktopState |
| 444 | if err := json.Unmarshal(raw, &saved); err != nil { |
| 445 | t.Fatal(err) |
| 446 | } |
| 447 | if saved.ActiveThemeID != "" { |
| 448 | t.Fatalf("unknown non-plugin id must be erased from disk: %s", raw) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | func TestPluginThemeAssetRoute(t *testing.T) { |
| 453 | home := t.TempDir() |
| 454 | t.Setenv("REASONIX_HOME", home) |
| 455 | installPluginThemeFixture(t, home, "themery", true, []pluginThemeFixture{ |
| 456 | {fileName: "neon.reasonix-theme", manifest: testPluginThemeManifest("neon-dusk", "Neon Dusk"), withImage: true}, |
| 457 | }) |
| 458 | app := NewApp() |
| 459 | |
| 460 | list, err := app.ListThemePacks() |
| 461 | if err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | view := findThemePackView(list, "plugin:themery:neon-dusk") |
| 465 | if view == nil || view.BackgroundURL == "" { |
| 466 | t.Fatalf("plugin theme with background missing: %+v", view) |
| 467 | } |
| 468 | |
| 469 | mw := app.themeAssetMiddleware() |
| 470 | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 471 | w.WriteHeader(http.StatusTeapot) |
| 472 | })) |
| 473 | |
| 474 | // GET background served straight out of the plugin ZIP. |
| 475 | rec := httptest.NewRecorder() |
| 476 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, view.BackgroundURL, nil)) |
| 477 | if rec.Code != http.StatusOK { |
| 478 | t.Fatalf("GET bg status %d", rec.Code) |
| 479 | } |
| 480 | if ct := rec.Header().Get("Content-Type"); ct != "image/png" { |
| 481 | t.Fatalf("bg content-type %q", ct) |
| 482 | } |
| 483 | if rec.Body.Len() == 0 { |
| 484 | t.Fatal("bg body empty") |
| 485 | } |
| 486 | |
| 487 | // Wrong digest → 404. |
| 488 | rec = httptest.NewRecorder() |
| 489 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"plugin:themery:neon-dusk/deadbeefdeadbeef/background.png", nil)) |
| 490 | if rec.Code != http.StatusNotFound { |
| 491 | t.Fatalf("wrong digest status %d", rec.Code) |
| 492 | } |
| 493 | |
| 494 | // Undeclared filename → 404. |
| 495 | pt := findPluginTheme("themery", "neon-dusk") |
| 496 | if pt == nil { |
| 497 | t.Fatal("plugin theme lookup failed") |
| 498 | } |
| 499 | rec = httptest.NewRecorder() |
| 500 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, themeAssetURLPrefix+"plugin:themery:neon-dusk/"+pt.digests["background.png"]+"/theme.json", nil)) |
| 501 | if rec.Code != http.StatusNotFound { |
| 502 | t.Fatalf("undeclared file status %d", rec.Code) |
| 503 | } |
| 504 | |
| 505 | // Plugin disabled → the asset stops resolving (read-only live view). |
| 506 | if err := pluginpkg.SetEnabled(home, "themery", false); err != nil { |
| 507 | t.Fatal(err) |
| 508 | } |
| 509 | rec = httptest.NewRecorder() |
| 510 | handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, view.BackgroundURL, nil)) |
| 511 | if rec.Code != http.StatusNotFound { |
| 512 | t.Fatalf("disabled plugin asset status %d", rec.Code) |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | func TestActivateThemePackRejectsMalformedPluginID(t *testing.T) { |
| 517 | home := t.TempDir() |
| 518 | t.Setenv("REASONIX_HOME", home) |
| 519 | app := NewApp() |
| 520 | if err := app.ActivateThemePack("plugin:themery"); err == nil { |
| 521 | t.Fatal("malformed plugin theme id must be rejected") |
| 522 | } |
| 523 | if err := app.ActivateThemePack("plugin:themery:Neon"); err == nil { |
| 524 | t.Fatal("plugin theme id must keep themePackIDRe on the inner id") |
| 525 | } |
| 526 | } |
| 527 |