package lua import ( "os" "path/filepath" "strings" "testing" ) // TestUnknownDeclarationFieldIsReported is the property the decoder exists for. // // Before it, a declaration field the gateway did not recognise was dropped with // no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off — // restricted nothing and was indistinguishable from a plugin that simply chose // not to declare an audience, so the misconfiguration survived until someone read // the .lua by hand. // // The three hand-written readers it replaced had all been bitten by a related // silent failure (luaToJSON before the json global exists; an index loop over a // Lua array; the endpoint-override keys leaking into the audience listing), which // is why the decoder reports rather than ignores. func TestUnknownDeclarationFieldIsReported(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "typo.lua") src := `local P = {} P.name = "typo" P.version = "1.0.0" P.ui = { visibilty = "admin" } return P ` if err := os.WriteFile(path, []byte(src), 0o644); err != nil { t.Fatal(err) } vm := NewVM(filepath.Join(dir, "adapters")) if err := vm.Start(); err != nil { t.Fatal(err) } t.Cleanup(vm.Stop) ps := NewPlugins(vm, filepath.Join(dir, "plugins")) if err := ps.LoadSource("typo", src); err != nil { t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err) } ctx := &decodeCtx{plugin: "typo"} ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"}) if len(ctx.problems) == 0 { t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes") } if !strings.Contains(ctx.problems[0], "visibilty") { t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0]) } // And it must be actionable, not just a complaint. if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") { t.Errorf("diagnostic offers no fix: %q", ctx.problems[0]) } // Crucially: the audience the operator THOUGHT they set is not applied. if ui.Visibility != "" { t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility) } } // TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct // declaration. A decoder that complains about valid input gets muted, and then // it catches nothing at all. func TestKnownFieldsProduceNoDiagnostics(t *testing.T) { ctx := &decodeCtx{plugin: "billing"} ui := decodeUIExtension(ctx, map[string]interface{}{ "visibility": "admin", "state_scope": map[string]interface{}{ "global": []interface{}{"total", "by_source"}, "depth": map[string]interface{}{"by_key": float64(1)}, }, "page": map[string]interface{}{"page_id": "billing", "mount": "
"}, "elements": []interface{}{ map[string]interface{}{"target": "status", "mount": "", "order": float64(1)}, }, }) if len(ctx.problems) != 0 { t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems) } if ui.Visibility != "admin" { t.Errorf("Visibility = %q, want admin", ui.Visibility) } if ui.StateScope == nil || len(ui.StateScope.Global) != 2 { t.Errorf("state_scope not decoded: %+v", ui.StateScope) } if ui.Page == nil || ui.Page.PageID != "billing" { t.Errorf("page not decoded: %+v", ui.Page) } if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" { t.Errorf("elements not decoded: %+v", ui.Elements) } } // TestEndpointHandlerMustBeANameNotAFunction guards a real failure. // // asString coerces anything non-string with fmt.Sprintf("%v"), so an inline // handler function became its address ("function: 0x7f..."). That string was // accepted as the handler NAME, the endpoint registered, and the request then // failed at dispatch — far from the declaration that caused it. Rejecting the // value at decode time names both the field and the path. func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) { dir := t.TempDir() vm := NewVM(filepath.Join(dir, "adapters")) if err := vm.Start(); err != nil { t.Fatal(err) } t.Cleanup(vm.Stop) ps := NewPlugins(vm, filepath.Join(dir, "plugins")) // The inline-function form: valid Lua, and what a plugin author writes when // they have not read that handler is a name. src := `local P = {} P.name = "inline" P.version = "1.0.0" P.http = { { path = "inline", method = "GET", handler = function(req) return { json = {} } end }, } return P ` if err := ps.LoadSource("inline", src); err != nil { t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err) } p := ps.find("inline") if p == nil { t.Fatal("plugin not found") } if len(p.http) != 0 { t.Errorf("the inline-function endpoint was accepted: %+v", p.http) } // And the documented form still works. src2 := `local P = {} P.name = "byname" P.version = "1.0.0" P.http = { { path = "ok", method = "GET", roles = { "admin" }, handler = "ok" }, } function P.ok(req) return { json = {} } end return P ` if err := ps.LoadSource("byname", src2); err != nil { t.Fatal(err) } p2 := ps.find("byname") if p2 == nil || len(p2.http) != 1 { t.Fatalf("the documented form regressed: %+v", p2) } if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 { t.Errorf("endpoint decoded wrong: %+v", p2.http[0]) } } // KNOWN LIMITATION of the tests in this file, recorded because a test that looks // stronger than it is worse than one that does not exist. // // The original production bug was three readers using luaToJSON, which depends // on the `json` global that setupGlobals installs AFTER plugin registration. A // mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here: // these tests drive decodeUIExtension with an already-decoded Go map, so the // decode edge — which is exactly where the bug lived — is not exercised. // // Making it catchable needs a test that loads a plugin through the real // registration path with json deliberately absent, and asserts that a declared // field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go // through that path, but its assertions are about the diagnostic path, not about // whether the decode returned the values. // // Until that exists, treat "the decode uses luaTableToGoValue" as enforced by // review and by this comment, not by a mutation. // TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above. // // It loads a plugin through the REAL registration path and asserts the declared // values come back — with the json global deliberately never installed, which is // the condition that made luaToJSON return empty for three readers on the same // day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this // test the mutation passed. func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) { dir := t.TempDir() vm := NewVM(filepath.Join(dir, "adapters")) if err := vm.Start(); err != nil { t.Fatal(err) } t.Cleanup(vm.Stop) ps := NewPlugins(vm, filepath.Join(dir, "plugins")) src := `local P = {} P.name = "nojson" P.version = "1.0.0" P.ui = { visibility = "admin", page = { page_id = "nojson", mount = "" }, } P.http = { { path = "ping", method = "GET", roles = { "admin" }, handler = "ping" }, } function P.ping(req) return { json = { ok = true } } end return P ` if err := ps.LoadSource("nojson", src); err != nil { t.Fatalf("load: %v", err) } p := ps.find("nojson") if p == nil { t.Fatal("plugin not found after LoadSource") } if p.UI == nil { t.Fatal("ui not read back") } if p.UI.Visibility != "admin" { t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+ "what luaToJSON does before the json global exists", p.UI.Visibility) } if p.UI.Page == nil || p.UI.Page.PageID != "nojson" { t.Errorf("UI.Page not decoded: %+v", p.UI.Page) } if len(p.http) != 1 { t.Fatalf("http endpoints = %d, want 1", len(p.http)) } if p.http[0].Handler != "ping" || p.http[0].Method != "GET" { t.Errorf("endpoint decoded wrong: %+v", p.http[0]) } if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" { t.Errorf("roles not decoded: %+v", p.http[0].Roles) } }