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") || strings.Contains(string(raw), "api_key") { t.Fatalf("the model serializes its key, even redacted: %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) } // A repeater whose middle item was deleted keeps every remaining key, // in numeric order (WR-05). sparse := write("sparse.json", `{"models":{"10":{"name":"Ten","model":"m10","api_key":"k10"},"0":{"name":"Zero","model":"m0","api_key":"k0"},"2":{"name":"Two","model":"m2","api_key":"k2"}}}`) if out, err := run(app, sparse); err != nil || !strings.Contains(out, "Imported 3 AI models.") { t.Fatalf("sparse import = %q %v", out, err) } rows = nil if err := db.Order("sort_order").Find(&rows).Error; err != nil { t.Fatal(err) } if len(rows) != 3 || rows[0].Name != "Zero" || rows[1].Name != "Two" || rows[2].Name != "Ten" { t.Fatalf("sparse rows = %+v", rows) } 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") } }