From 70314b508de421da14049511f5a923a8379ba31d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 4 Oct 2026 01:39:52 +0200 Subject: [PATCH] test: the AI models admin schemas compile with a write-only key, and golem:import-settings runs as the CLI runs it - TestGolemAdminSchemas: no key column in the list, a password api_key field, the access_settings permission, a model that never serializes its key, the admin hooks' refusals - TestGolemImportCommand: missing app or database, the notice path, a numbered repeater with PHP's scalar forms, a refused second run and malformed or missing files --- golem_admin_test.go | 170 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 golem_admin_test.go diff --git a/golem_admin_test.go b/golem_admin_test.go new file mode 100644 index 0000000..3bb5619 --- /dev/null +++ b/golem_admin_test.go @@ -0,0 +1,170 @@ +package golem + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/sm-golem-plugin/console" + "git.golem15.com/golem15/sm-golem-plugin/controllers" + "git.golem15.com/golem15/sm-golem-plugin/internal/pgtest" + "git.golem15.com/golem15/sm-golem-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// TestGolemAdminSchemas compiles the AI models admin YAML under cabana: the +// list shows no key column, the form's api_key is a password input (write +// only, T-14-24), the screen requires golem15.golem.access_settings, the +// model hides its key from every serialization, and the plugin hands its +// migrations and models to the framework. +func TestGolemAdminSchemas(t *testing.T) { + p := &Plugin{} + ctl := p.AdminControllers()[0] + list, err := cabana.CompileList("golem15.golem", ctl, p.AdminFS()) + if err != nil { + t.Fatal(err) + } + form, err := cabana.CompileForm("golem15.golem", ctl, p.AdminFS()) + if err != nil { + t.Fatal(err) + } + listJSON, _ := json.Marshal(list) + formJSON, _ := json.Marshal(form) + if bytes.Contains(listJSON, []byte("api_key")) { + t.Fatalf("the list shows the key: %s", listJSON) + } + for _, col := range []string{`"name"`, `"adapter"`, `"model"`, `"is_enabled"`, `"sort_order"`} { + if !bytes.Contains(listJSON, []byte(col)) { + t.Errorf("the list lacks %s: %s", col, listJSON) + } + } + if !bytes.Contains(formJSON, []byte(`"api_key"`)) || !bytes.Contains(formJSON, []byte(`"password"`)) { + t.Fatalf("the form's api_key is not a password input: %s", formJSON) + } + type permissioned interface{ RequiredPermissions() []string } + if pc, ok := ctl.(permissioned); !ok || strings.Join(pc.RequiredPermissions(), ",") != controllers.PermissionAccessSettings { + t.Fatalf("the models screen permissions = %v", ctl) + } + if hidden := (models.AiModel{}).Hidden(); strings.Join(hidden, ",") != "api_key" { + t.Errorf("Hidden() = %v", hidden) + } + m := models.AiModel{Name: "Hidden", APIKey: lagoon.NewEncrypted("sk-hidden-0001")} + raw, _ := json.Marshal(m) + if strings.Contains(string(raw), "sk-hidden-0001") { + t.Fatalf("the model serializes its key: %s", raw) + } + if len(models.All()) == 0 || len(p.Models()) == 0 || len(p.Migrations()) == 0 { + t.Fatal("the plugin hands no models or migrations to the framework") + } + + // The admin hooks refuse a record of another type, and an update + // outside cabana's transaction. + c := controllers.ModelsAdminController{} + if err := c.FormBeforeCreate(context.Background(), &struct{}{}); err == nil { + t.Error("FormBeforeCreate accepted a foreign record") + } + if err := c.FormBeforeUpdate(context.Background(), &struct{}{}); err == nil { + t.Error("FormBeforeUpdate accepted a foreign record") + } + if err := c.FormBeforeUpdate(context.Background(), &models.AiModel{}); err == nil { + t.Error("FormBeforeUpdate ran without a transaction") + } + if err := c.FormBeforeUpdate(context.Background(), &models.AiModel{APIKey: lagoon.NewEncrypted("sk-new-0001")}); err != nil { + t.Errorf("a new key needs no stored one: %v", err) + } +} + +// stubInput is a bonfire.Input with one --file flag. +type stubInput struct{ file string } + +func (s stubInput) Args() []string { return nil } +func (s stubInput) Argument(string) (string, bool) { return "", false } +func (s stubInput) Flags(string) []string { return nil } +func (s stubInput) Flag(name string) (string, bool) { return s.file, name == "file" && s.file != "" } + +// TestGolemImportCommand runs golem:import-settings as the CLI does: it +// refuses a missing app or database, reports a clean notice as info and +// exit 0, imports a file, and fails on unreadable or malformed settings, +// reading every repeater shape and scalar form PHP stores. +func TestGolemImportCommand(t *testing.T) { + ctx := context.Background() + run := func(app *backpack.App, file string) (string, error) { + var buf bytes.Buffer + err := console.ImportSettings(app).Run(ctx, stubInput{file: file}, bonfire.NewOutput(nil, &buf, &buf)) + return buf.String(), err + } + if _, err := run(nil, ""); err == nil { + t.Fatal("a nil app must fail") + } + bare := backpack.New(testConfig(t)) + if _, err := run(bare, ""); err == nil { + t.Fatal("an app without a database must fail") + } + db := pgtest.DB(t) + app := backpack.New(testConfig(t)) + if err := lagoon.Publish(app, pgtest.SQL(t), db); err != nil { + t.Fatal(err) + } + if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil { + t.Fatal(err) + } + if out, err := run(app, ""); err != nil || !strings.Contains(out, "nothing to import") { + t.Fatalf("a missing settings table = %q %v", out, err) + } + dir := t.TempDir() + write := func(name, body string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path + } + // A repeater saved with numbered keys, scalars as numbers and booleans. + numbered := write("numbered.json", `{"models":{"0":{"name":"Zero","model":"m0","api_key":"k0","is_enabled":1,"is_default":true,"accepts_images":"1","max_completion_tokens":2048},`+ + `"1":{"name":7,"model":true,"api_key":"k1","is_enabled":0,"generates_images":"0","max_completion_tokens":"x"},"2":"not an item"}}`) + out, err := run(app, numbered) + if err != nil || !strings.Contains(out, "Imported 2 AI models.") { + t.Fatalf("numbered import = %q %v", out, err) + } + var rows []models.AiModel + if err := db.Order("sort_order").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 2 || !rows[0].IsEnabled || !rows[0].IsDefault || !rows[0].AcceptsImages || *rows[0].MaxCompletionTokens != 2048 || + rows[1].Name != "7" || rows[1].Model != "1" || rows[1].IsEnabled || rows[1].MaxCompletionTokens != nil { + t.Fatalf("numbered rows = %+v", rows) + } + if out, err := run(app, numbered); !errors.Is(err, console.ErrModelsNotEmpty) || !strings.Contains(out, "already has rows") { + t.Fatalf("a second import = %q %v", out, err) + } + if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{"empty.json": `{"models":[]}`, "null.json": `{"models":null}`} { + if out, err := run(app, write(name, body)); err != nil || !strings.Contains(out, "no AI models") { + t.Errorf("%s = %q %v", name, out, err) + } + } + for name, body := range map[string]string{"bad.json": `{not json`, "scalar.json": `{"models":"nope"}`} { + if _, err := run(app, write(name, body)); err == nil { + t.Errorf("%s was imported", name) + } + } + if _, err := run(app, filepath.Join(dir, "missing.json")); err == nil { + t.Error("a missing file was imported") + } + if pgtest.SQL(t) == nil { + t.Fatal("pgtest.SQL") + } +}