2026-08-01 22:06:14 +02:00
//go:build !wazero
2026-07-25 14:47:21 +00:00
// Registry lifecycle tests for the default (non-wazero) build.
//
2026-08-01 22:06:14 +02:00
// The build constraint above is load-bearing, not decorative: these tests
// assert activation fails with ErrRuntimeUnavailable, which is only true
// when no runtime is linked in. Under -tags wazero a real runtime exists
// and two of them failed. The file always intended to be default-only (see
// the paragraph below); it just never carried the tag.
//
2026-07-25 14:47:21 +00:00
// registry.go is the largest source file in the plugin package and its
// lifecycle half — Sink, activate, EnablePlugin, DisablePlugin,
// UninstallPlugin, List, UITabBindings — had no coverage. The wazero-tagged
// build has its own activation tests in sandbox_wazero_test.go; what is pinned
// here is the behaviour that holds *without* a runtime: enabling must roll the
// store flag back when activation fails, disabling must drop command bindings,
// and uninstalling must remove the on-disk directory so the plugin is not
// resurrected by the next LoadAll.
package plugin
import (
"archive/zip"
"bytes"
"context"
"errors"
2026-08-14 10:05:40 +02:00
"fmt"
2026-07-25 14:47:21 +00:00
"os"
"path/filepath"
"testing"
)
// newRegistryWithDir builds a registry backed by a real in-memory database and
// a temp plugin directory, and returns both.
func newRegistryWithDir ( t * testing . T ) ( * Registry , PluginStore , string ) {
t . Helper ()
dir := t . TempDir ()
store := openPluginTestDB ( t )
r , err := NewRegistry ( Config { Directory : dir , Store : store })
if err != nil {
t . Fatalf ( "NewRegistry: %v" , err )
}
t . Cleanup ( func () { _ = r . Close ( context . Background ()) })
return r , store , dir
}
// writePluginDir lays a minimal valid plugin out on disk and returns its path.
func writePluginDir ( t * testing . T , root , name , manifestJSON string ) string {
t . Helper ()
dir := filepath . Join ( root , name )
if err := os . MkdirAll ( dir , 0 o750 ); err != nil {
t . Fatalf ( "mkdir %s: %v" , dir , err )
}
if err := os . WriteFile ( filepath . Join ( dir , "plugin.json" ), [] byte ( manifestJSON ), 0 o600 ); err != nil {
t . Fatalf ( "write plugin.json: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( dir , name + ".wasm" ), [] byte ( "\x00asm\x01\x00\x00\x00" ), 0 o600 ); err != nil {
t . Fatalf ( "write wasm: %v" , err )
}
return dir
}
func simpleManifest ( name string ) string {
return `{"name":"` + name + `","version":"1.0.0","entrypoint":"` + name + `.wasm","permissions":["storage"]}`
}
// ─── NewRegistry / Sink ─────────────────────────────────────────────────────
func TestNewRegistry_RequiresStore ( t * testing . T ) {
if _ , err := NewRegistry ( Config { Directory : t . TempDir ()}); err == nil {
t . Error ( "NewRegistry with a nil Store succeeded; want an error" )
}
}
func TestRegistry_Sink ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
sink := r . Sink ()
if sink == nil {
t . Fatal ( "Sink() = nil; the hub dereferences this on every broadcast" )
}
if r . Sink () != sink {
t . Error ( "Sink() returned a different EventSink on the second call; it must be stable" )
}
}
// ─── LoadAll / List ─────────────────────────────────────────────────────────
func TestRegistry_LoadAll_RegistersDiscoveredPlugins ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
writePluginDir ( t , dir , "beta" , simpleManifest ( "beta" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
list := r . List ()
if len ( list ) != 2 {
t . Fatalf ( "List() has %d entries after LoadAll, want 2" , len ( list ))
}
for _ , inst := range list {
if inst . Enabled {
t . Errorf ( "plugin %q is enabled straight after LoadAll; installs must default to disabled" , inst . Manifest . Name )
}
}
rows , err := store . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( rows ) != 2 {
t . Errorf ( "store has %d rows, want 2 — LoadAll must persist discovered manifests" , len ( rows ))
}
}
2026-08-19 16:32:07 +02:00
// OC-0165: one malformed plugin directory must not blank the whole registry.
// LoadAll must still install and activate every good plugin alongside a
// broken one, matching installFromDisk's own per-plugin-failure policy a few
// lines below (a bad plugin there just gets `slog.Warn` + `continue`).
func TestRegistry_LoadAll_InstallsGoodPluginsDespiteOneBadDirectory ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
// "broken" has a plugin.json that fails to parse — malformed JSON.
brokenDir := filepath . Join ( dir , "broken" )
if err := os . MkdirAll ( brokenDir , 0 o750 ); err != nil {
t . Fatalf ( "mkdir %s: %v" , brokenDir , err )
}
if err := os . WriteFile ( filepath . Join ( brokenDir , "plugin.json" ), [] byte ( `{"name":"broken",}` ), 0 o600 ); err != nil {
t . Fatalf ( "write plugin.json: %v" , err )
}
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v — a malformed plugin directory must not fail the whole load" , err )
}
list := r . List ()
if len ( list ) != 1 {
t . Fatalf ( "List() has %d entries after LoadAll, want 1 (alpha installed despite broken's failure); got %+v" , len ( list ), list )
}
if list [ 0 ]. Manifest . Name != "alpha" {
t . Errorf ( "List()[0].Manifest.Name = %q, want \"alpha\"" , list [ 0 ]. Manifest . Name )
}
rows , err := store . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( rows ) != 1 {
t . Errorf ( "store has %d rows, want 1 — the good plugin must still be persisted" , len ( rows ))
}
}
2026-07-25 14:47:21 +00:00
func TestRegistry_LoadAll_RemovesStaleStagingDirs ( t * testing . T ) {
r , _ , dir := newRegistryWithDir ( t )
// A crash mid-InstallFromZip leaves a ".install-XXXX" directory behind.
stale := filepath . Join ( dir , ".install-abc123" )
if err := os . MkdirAll ( stale , 0 o750 ); err != nil {
t . Fatalf ( "mkdir stale: %v" , err )
}
if err := r . LoadAll ( context . Background ()); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
if _ , err := os . Stat ( stale ); ! os . IsNotExist ( err ) {
t . Errorf ( "stale staging dir survived LoadAll (stat err = %v)" , err )
}
}
func TestRegistry_List_IsASnapshot ( t * testing . T ) {
r , _ , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
first := r . List ()
if len ( first ) != 1 {
t . Fatalf ( "List() = %d entries, want 1" , len ( first ))
}
// Mutating the returned slice must not affect the registry.
first [ 0 ] = nil
second := r . List ()
if len ( second ) != 1 || second [ 0 ] == nil {
t . Error ( "mutating the slice returned by List() corrupted the registry's own state" )
}
}
// ─── activate (default build) ───────────────────────────────────────────────
func TestRegistry_Activate_WithoutRuntime ( t * testing . T ) {
r , _ , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
err := r . activate ( ctx , inst )
if ! errors . Is ( err , ErrRuntimeUnavailable ) {
t . Errorf ( "activate without a runtime = %v, want ErrRuntimeUnavailable" , err )
}
}
func TestRegistry_Activate_AfterClose ( t * testing . T ) {
r , _ , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
if err := r . Close ( ctx ); err != nil {
t . Fatalf ( "Close: %v" , err )
}
// Close nils runtimePlatform under the lock; activate must observe that
// and refuse rather than call into a torn-down runtime.
if err := r . activate ( ctx , inst ); ! errors . Is ( err , ErrRuntimeUnavailable ) {
t . Errorf ( "activate after Close = %v, want ErrRuntimeUnavailable" , err )
}
if got := r . List (); len ( got ) != 0 {
t . Errorf ( "List() = %d entries after Close, want 0" , len ( got ))
}
}
// ─── EnablePlugin ───────────────────────────────────────────────────────────
func TestRegistry_EnablePlugin_RollsBackWhenActivationFails ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
// The default build has no runtime, so activation always fails. What
// matters is that the failure leaves no half-enabled state behind.
err := r . EnablePlugin ( ctx , inst . ID )
if ! errors . Is ( err , ErrRuntimeUnavailable ) {
t . Fatalf ( "EnablePlugin = %v, want ErrRuntimeUnavailable" , err )
}
if inst . Enabled {
t . Error ( "in-memory Enabled flag stayed true after a failed activation" )
}
row , err := store . GetPlugin ( ctx , inst . ID )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if row . Enabled {
t . Error ( "store row stayed enabled after a failed activation; the rollback did not run" )
}
}
func TestRegistry_EnablePlugin_UnknownID ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
if err := r . EnablePlugin ( context . Background (), 999 ); ! errors . Is ( err , ErrPluginNotFound ) {
t . Errorf ( "EnablePlugin on an unknown id = %v, want ErrPluginNotFound" , err )
}
}
2026-08-14 10:05:40 +02:00
// OC-0126: EnablePlugin sets the DB flag before it looks the in-memory
// instance up. When the store row survives but the instance does not (the
// on-disk manifest vanished without going through UninstallPlugin), the
// early `!ok` return must roll the DB flag back the same way the
// activation-failure path below it already does — otherwise the row is
// permanently stuck at enabled=1 with no runtime instance to match it.
func TestRegistry_EnablePlugin_RollsBackWhenInstanceMissing ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
id := inst . ID
// Simulate the plugin's on-disk manifest disappearing independently of
// UninstallPlugin (e.g. a manual directory delete): the store row
// survives but the in-memory registration does not.
r . mu . Lock ()
delete ( r . plugins , id )
delete ( r . byName , inst . Manifest . Name )
r . mu . Unlock ()
err := r . EnablePlugin ( ctx , id )
if ! errors . Is ( err , ErrPluginNotFound ) {
t . Fatalf ( "EnablePlugin on a store-only row = %v, want ErrPluginNotFound" , err )
}
row , getErr := store . GetPlugin ( ctx , id )
if getErr != nil {
t . Fatalf ( "GetPlugin: %v" , getErr )
}
if row . Enabled {
t . Error ( "store row stayed enabled after EnablePlugin found no in-memory instance; the rollback did not run" )
}
}
2026-07-25 14:47:21 +00:00
// ─── DisablePlugin ──────────────────────────────────────────────────────────
func TestRegistry_DisablePlugin_ClearsFlagAndCommands ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
// Simulate an activated plugin that owns a command binding.
r . mu . Lock ()
inst . Enabled = true
r . commands [ "greet" ] = inst
r . mu . Unlock ()
if err := r . DisablePlugin ( ctx , inst . ID ); err != nil {
t . Fatalf ( "DisablePlugin: %v" , err )
}
if inst . Enabled {
t . Error ( "Enabled flag survived DisablePlugin" )
}
r . mu . RLock ()
_ , stillBound := r . commands [ "greet" ]
r . mu . RUnlock ()
if stillBound {
t . Error ( "command binding survived DisablePlugin; dispatch would still route into a torn-down plugin" )
}
row , err := store . GetPlugin ( ctx , inst . ID )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if row . Enabled {
t . Error ( "store row stayed enabled after DisablePlugin" )
}
}
func TestRegistry_DisablePlugin_UnknownIDIsNoOp ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
// The store UPDATE matches no rows and the in-memory lookup misses; this
// must not error, so an admin can disable an already-removed plugin.
if err := r . DisablePlugin ( context . Background (), 999 ); err != nil {
t . Errorf ( "DisablePlugin on an unknown id = %v, want nil" , err )
}
}
// ─── UninstallPlugin ────────────────────────────────────────────────────────
func TestRegistry_UninstallPlugin_RemovesRowAndDirectory ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
pluginDir := writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
if err := r . UninstallPlugin ( ctx , inst . ID ); err != nil {
t . Fatalf ( "UninstallPlugin: %v" , err )
}
if got := r . List (); len ( got ) != 0 {
t . Errorf ( "List() = %d entries after uninstall, want 0" , len ( got ))
}
rows , err := store . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( rows ) != 0 {
t . Errorf ( "store has %d rows after uninstall, want 0" , len ( rows ))
}
// The on-disk removal is what stops the next LoadAll from reinstalling it.
if _ , err := os . Stat ( pluginDir ); ! os . IsNotExist ( err ) {
t . Errorf ( "plugin directory survived uninstall (stat err = %v)" , err )
}
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll after uninstall: %v" , err )
}
if got := r . List (); len ( got ) != 0 {
t . Errorf ( "uninstalled plugin was resurrected by LoadAll: %d entries" , len ( got ))
}
}
2026-08-14 10:05:40 +02:00
// OC-0127: when the on-disk directory can't be removed, UninstallPlugin must
// report that instead of returning nil — the comment right above the removal
// says the removal is what stops scanPluginDirectory resurrecting the plugin
// on the next startup, so silently swallowing the error produces exactly the
// outcome the removal exists to prevent.
func TestRegistry_UninstallPlugin_ReturnsErrorWhenDirRemovalFails ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
pluginDir := writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
// Force the directory removal to fail. There's no portable,
// privilege-free way to make a real os.RemoveAll fail on Windows (it
// happily deletes read-only files), so the removeAll seam is overridden
// directly.
origRemoveAll := removeAll
removeAll = func ( string ) error { return fmt . Errorf ( "simulated removal failure" ) }
t . Cleanup ( func () { removeAll = origRemoveAll })
err := r . UninstallPlugin ( ctx , inst . ID )
if err == nil {
t . Fatal ( "UninstallPlugin succeeded despite a directory-removal failure; want an error" )
}
if _ , statErr := os . Stat ( pluginDir ); statErr != nil {
t . Errorf ( "plugin directory was actually removed despite the reported error (stat err = %v)" , statErr )
}
// The DB row and in-memory record are gone regardless — only the on-disk
// cleanup failed, and that failure must be visible to the caller.
rows , lErr := store . ListPlugins ( ctx )
if lErr != nil {
t . Fatalf ( "ListPlugins: %v" , lErr )
}
if len ( rows ) != 0 {
t . Errorf ( "store row survived a failed uninstall; want it removed regardless of directory cleanup" )
}
}
// OC-0133: UninstallPlugin must remove the plugin's real on-disk directory,
// not one rebuilt from manifest.Name. scanPluginDirectory keys the directory
// off the filesystem entry name, so a plugin dropped in a folder whose name
// differs from its manifest name (e.g. a hand-installed "hello-v2" holding
// manifest name "hello") is otherwise resurrected by the next LoadAll: the
// wrong path 404s os.RemoveAll into a silent no-op.
func TestRegistry_UninstallPlugin_DirNameDiffersFromManifestName ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
actualDir := filepath . Join ( dir , "hello-v2" )
if err := os . MkdirAll ( actualDir , 0 o750 ); err != nil {
t . Fatalf ( "mkdir: %v" , err )
}
manifestJSON := `{"name":"hello","version":"1.0.0","entrypoint":"hello.wasm","permissions":["storage"]}`
if err := os . WriteFile ( filepath . Join ( actualDir , "plugin.json" ), [] byte ( manifestJSON ), 0 o600 ); err != nil {
t . Fatalf ( "write manifest: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( actualDir , "hello.wasm" ), [] byte ( "\x00asm\x01\x00\x00\x00" ), 0 o600 ); err != nil {
t . Fatalf ( "write wasm: %v" , err )
}
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
if inst . Manifest . Name != "hello" {
t . Fatalf ( "test setup: manifest name = %q, want hello" , inst . Manifest . Name )
}
if err := r . UninstallPlugin ( ctx , inst . ID ); err != nil {
t . Fatalf ( "UninstallPlugin: %v" , err )
}
if _ , statErr := os . Stat ( actualDir ); ! os . IsNotExist ( statErr ) {
t . Errorf ( "actual plugin directory %q survived uninstall (stat err = %v)" , actualDir , statErr )
}
// The real bug symptom: a stale directory makes the plugin come back.
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll after uninstall: %v" , err )
}
if got := r . List (); len ( got ) != 0 {
t . Errorf ( "uninstalled plugin was resurrected by LoadAll: %d entries" , len ( got ))
}
rows , err := store . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( rows ) != 0 {
t . Errorf ( "store still has %d rows after uninstall+reload, want 0" , len ( rows ))
}
}
2026-07-25 14:47:21 +00:00
func TestRegistry_UninstallPlugin_UnknownID ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
if err := r . UninstallPlugin ( context . Background (), 999 ); err != nil {
t . Errorf ( "UninstallPlugin on an unknown id = %v, want nil" , err )
}
}
// ─── UITabBindings ──────────────────────────────────────────────────────────
func TestRegistry_UITabBindings ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
if got := r . UITabBindings (); len ( got ) != 0 {
t . Errorf ( "UITabBindings() = %v on a fresh registry, want empty" , got )
}
binding := UITabBinding {
PluginID : 7 ,
PluginName : "alpha" ,
Tab : UITab { ID : "main" , Label : "Alpha" , Asset : "index.html" },
}
r . mu . Lock ()
r . uiTabs = append ( r . uiTabs , binding )
r . mu . Unlock ()
got := r . UITabBindings ()
if len ( got ) != 1 || got [ 0 ]. PluginName != "alpha" {
t . Fatalf ( "UITabBindings() = %+v, want the one registered binding" , got )
}
// The returned slice is a copy — the client bridge must not be able to
// rewrite the registry's bindings through it.
got [ 0 ]. PluginName = "mutated"
if again := r . UITabBindings (); again [ 0 ]. PluginName != "alpha" {
t . Errorf ( "UITabBindings() returned the backing array; caller mutation leaked as %q" , again [ 0 ]. PluginName )
}
}
// ─── InstallFromZip ─────────────────────────────────────────────────────────
// buildZip assembles an in-memory zip from name→content pairs.
func buildZip ( t * testing . T , files map [ string ] string ) [] byte {
t . Helper ()
var buf bytes . Buffer
zw := zip . NewWriter ( & buf )
for name , content := range files {
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "zip create %s: %v" , name , err )
}
if _ , err := w . Write ([] byte ( content )); err != nil {
t . Fatalf ( "zip write %s: %v" , name , err )
}
}
if err := zw . Close (); err != nil {
t . Fatalf ( "zip close: %v" , err )
}
return buf . Bytes ()
}
func TestRegistry_InstallFromZip_Success ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
zipBytes := buildZip ( t , map [ string ] string {
"plugin.json" : simpleManifest ( "zipped" ),
"zipped.wasm" : "\x00asm\x01\x00\x00\x00" ,
})
name , err := r . InstallFromZip ( ctx , zipBytes )
if err != nil {
t . Fatalf ( "InstallFromZip: %v" , err )
}
if name != "zipped" {
t . Errorf ( "name = %q, want %q" , name , "zipped" )
}
if _ , err := os . Stat ( filepath . Join ( dir , "zipped" , "plugin.json" )); err != nil {
t . Errorf ( "plugin was not staged into the plugin directory: %v" , err )
}
rows , err := store . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( rows ) != 1 || rows [ 0 ]. Name != "zipped" {
t . Errorf ( "store rows = %+v, want one row named zipped" , rows )
}
// No staging directory may be left behind on the success path.
entries , err := os . ReadDir ( dir )
if err != nil {
t . Fatalf ( "ReadDir: %v" , err )
}
for _ , e := range entries {
if len ( e . Name ()) > 9 && e . Name ()[: 9 ] == ".install-" {
t . Errorf ( "staging dir %q survived a successful install" , e . Name ())
}
}
}
2026-08-14 10:05:40 +02:00
// OC-0104: InstallFromZip is the hot-upgrade path (unlike LoadAll, nothing
// downstream of it calls activateAll). installFromDisk always registers the
// fresh Instance with Enabled: false and InstallPlugin's upsert never touches
// the `enabled` column, so a plugin that was enabled before the upgrade must
// come out the other side with its store row and its runtime instance still
// agreeing — otherwise the DB says enabled while the module is actually
// unloaded, and the admin panel has no reason to prompt a re-enable.
func TestRegistry_InstallFromZip_ReactivatesPreviouslyEnabledPlugin ( t * testing . T ) {
r , store , dir := newRegistryWithDir ( t )
ctx := context . Background ()
writePluginDir ( t , dir , "alpha" , simpleManifest ( "alpha" ))
if err := r . LoadAll ( ctx ); err != nil {
t . Fatalf ( "LoadAll: %v" , err )
}
inst := r . List ()[ 0 ]
// Simulate a previously-activated, enabled plugin — what activateAll
// leaves behind under a real (wazero-tagged) runtime: DB row enabled and
// the in-memory flag set to match.
if err := store . EnablePlugin ( ctx , inst . ID ); err != nil {
t . Fatalf ( "EnablePlugin (setup): %v" , err )
}
r . mu . Lock ()
inst . Enabled = true
r . mu . Unlock ()
// Upload a new version of the same plugin — the runtime-upgrade path.
zipBytes := buildZip ( t , map [ string ] string {
"plugin.json" : simpleManifest ( "alpha" ),
"alpha.wasm" : "\x00asm\x01\x00\x00\x00" ,
})
if _ , err := r . InstallFromZip ( ctx , zipBytes ); err != nil {
t . Fatalf ( "InstallFromZip: %v" , err )
}
newInst := r . List ()[ 0 ]
row , err := store . GetPlugin ( ctx , newInst . ID )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if row . Enabled != newInst . Enabled {
t . Errorf ( "store row Enabled=%v but runtime instance Enabled=%v after InstallFromZip; a hot upgrade must never leave these disagreeing" , row . Enabled , newInst . Enabled )
}
}
2026-07-25 14:47:21 +00:00
func TestRegistry_InstallFromZip_Rejections ( t * testing . T ) {
tests := [] struct {
name string
files map [ string ] string
}{
{
name : "missing plugin.json" ,
files : map [ string ] string { "stray.wasm" : "\x00asm\x01\x00\x00\x00" },
},
{
name : "path traversal entry" ,
files : map [ string ] string {
"../escape.txt" : "nope" ,
"plugin.json" : simpleManifest ( "evil" ),
"evil.wasm" : "\x00asm\x01\x00\x00\x00" ,
},
},
{
name : "entrypoint missing from archive" ,
files : map [ string ] string {
"plugin.json" : simpleManifest ( "ghost" ),
},
},
{
name : "unparseable manifest" ,
files : map [ string ] string {
"plugin.json" : `{"name":"bad"` ,
"bad.wasm" : "\x00asm\x01\x00\x00\x00" ,
},
},
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
r , _ , dir := newRegistryWithDir ( t )
_ , err := r . InstallFromZip ( context . Background (), buildZip ( t , tt . files ))
if err == nil {
t . Fatal ( "InstallFromZip succeeded; want a rejection" )
}
// Every rejection path must clean its staging directory up.
entries , readErr := os . ReadDir ( dir )
if readErr != nil {
t . Fatalf ( "ReadDir: %v" , readErr )
}
for _ , e := range entries {
if len ( e . Name ()) > 9 && e . Name ()[: 9 ] == ".install-" {
t . Errorf ( "staging dir %q survived a rejected install" , e . Name ())
}
}
})
}
}
func TestRegistry_InstallFromZip_NotConfigured ( t * testing . T ) {
store := openPluginTestDB ( t )
r , err := NewRegistry ( Config { Store : store }) // no Directory
if err != nil {
t . Fatalf ( "NewRegistry: %v" , err )
}
t . Cleanup ( func () { _ = r . Close ( context . Background ()) })
if _ , err := r . InstallFromZip ( context . Background (), [] byte ( "whatever" )); err == nil {
t . Error ( "InstallFromZip with no plugin directory succeeded; want an error" )
}
}
func TestRegistry_InstallFromZip_OversizeRejected ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
oversize := make ([] byte , maxZipBytes + 1 )
if _ , err := r . InstallFromZip ( context . Background (), oversize ); err == nil {
t . Error ( "InstallFromZip accepted a zip over maxZipBytes" )
}
}
func TestRegistry_InstallFromZip_InvalidArchive ( t * testing . T ) {
r , _ , _ := newRegistryWithDir ( t )
if _ , err := r . InstallFromZip ( context . Background (), [] byte ( "this is not a zip" )); err == nil {
t . Error ( "InstallFromZip accepted a non-zip payload" )
}
}
// ─── bytesReaderAt ──────────────────────────────────────────────────────────
func TestBytesReaderAt ( t * testing . T ) {
data := bytesReaderAt ( "hello world" )
buf := make ([] byte , 5 )
n , err := data . ReadAt ( buf , 0 )
if err != nil || n != 5 || string ( buf ) != "hello" {
t . Errorf ( "ReadAt(0) = (%d, %v, %q), want (5, nil, \"hello\")" , n , err , buf )
}
// A short read at the tail reports io.EOF alongside the bytes it managed
// to copy, which is what archive/zip expects.
tail := make ([] byte , 10 )
n , err = data . ReadAt ( tail , 6 )
if n != 5 || err == nil {
t . Errorf ( "ReadAt(6) = (%d, %v), want (5, io.EOF)" , n , err )
}
if string ( tail [: n ]) != "world" {
t . Errorf ( "tail = %q, want %q" , tail [: n ], "world" )
}
if _ , err := data . ReadAt ( buf , - 1 ); err == nil {
t . Error ( "ReadAt with a negative offset succeeded; want io.EOF" )
}
if _ , err := data . ReadAt ( buf , 999 ); err == nil {
t . Error ( "ReadAt past the end succeeded; want io.EOF" )
}
}