package golem import ( "context" "encoding/json" "errors" "maps" "os" "path/filepath" "strings" "testing" "git.golem15.com/golem15/sm-golem-plugin/console" "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/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" ) func testConfig(t testing.TB) *compass.Config { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: golem-test\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) if err != nil { t.Fatal(err) } return cfg } // TestGolemPluginBoot activates the plugin alone (no golem15.user: it // requires no other plugin) and checks its ID, its config defaults and its // lang namespace. func TestGolemPluginBoot(t *testing.T) { cfg := testConfig(t) app := backpack.New(cfg) plugins, err := party.Activate(app, []string{"golem15.golem"}) if err != nil { t.Fatalf("activate golem15.golem alone: %v", err) } if len(plugins) != 1 || plugins[0].ID() != "golem15.golem" { t.Fatalf("activated plugins = %v", plugins) } if got := (&Plugin{}).Requires(); len(got) != 0 { t.Fatalf("Requires() = %v, want none", got) } if got := cfg.Int("golem15.golem.ai.timeout_seconds"); got != 120 { t.Fatalf("golem15.golem.ai.timeout_seconds = %d, want 120", got) } hosts, ok := cfg.Lookup("golem15.golem.ssrf.allowed_hosts") if !ok { t.Fatal("golem15.golem.ssrf.allowed_hosts is not set") } list, _ := hosts.([]any) if len(list) != 2 || list[0] != "oaidalleapiprodscus.blob.core.windows.net" || list[1] != ".openai.com" { t.Fatalf("allowed_hosts = %#v", hosts) } tr, ok := app.Lookup[*phrasebook.Translator]() if !ok { t.Fatal("no translator published") } if got := tr.Get(context.Background(), "golem15.golem::lang.plugin.name", nil); got != "Golem" { t.Fatalf("golem15.golem::lang.plugin.name = %q", got) } if got := tr.Get(context.Background(), "golem15.golem::lang.permissions.access_settings", nil); got != "Manage AI settings" { t.Fatalf("access_settings label = %q", got) } } // TestImportSettings: golem:import-settings copies the WinterCMS // golem_settings repeater once, in order, encrypting each plaintext key; // a second run is refused; a missing system_settings table is a clean // notice. func TestImportSettings(t *testing.T) { ctx := context.Background() db := pgtest.DB(t) 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) } t.Cleanup(func() { db.Exec(`DROP TABLE IF EXISTS system_settings`) }) if cmd := console.ImportSettings(nil); cmd.Name != "golem:import-settings" || len(cmd.Flags) != 1 || cmd.Flags[0].Name != "file" { t.Fatalf("command = %+v", cmd) } if (&Plugin{}).Commands()[0].Name != "golem:import-settings" { t.Fatal("the plugin does not register golem:import-settings") } _, err := console.ImportModels(ctx, db, "") if !console.IsNothingToImport(err) || err.Error() != "No system_settings table: nothing to import." { t.Fatalf("missing table = %v", err) } if err := db.Exec(`CREATE TABLE system_settings (id SERIAL PRIMARY KEY, item TEXT, value TEXT)`).Error; err != nil { t.Fatal(err) } if _, err := console.ImportModels(ctx, db, ""); !console.IsNothingToImport(err) || err.Error() != "No golem_settings row in system_settings: nothing to import." { t.Fatalf("missing row = %v", err) } const plain = "sk-test-plain-01" value := `{"models":[` + `{"name":"Chat","adapter":"openai","api_key":"` + plain + `","base_url":"https://api.openai.com/v1","model":"gpt-4o","system_prompt":"","is_enabled":"1","is_default":"1","generates_images":"0","accepts_images":true,"has_files_endpoint":"0","max_completion_tokens":"4096"},` + `{"name":"Claude","adapter":"anthropic","api_key":"sk-ant-parity-0002","base_url":"","model":"claude-sonnet-4-6","system_prompt":"Be terse.","is_enabled":"0","is_default":"0","max_completion_tokens":8192}` + `]}` if err := db.Exec(`INSERT INTO system_settings (item, value) VALUES ('golem_settings', ?)`, value).Error; err != nil { t.Fatal(err) } n, err := console.ImportModels(ctx, db, "") if err != nil || n != 2 { t.Fatalf("import = %d, %v", n, 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].Name != "Chat" || rows[0].SortOrder != 0 || rows[1].Name != "Claude" || rows[1].SortOrder != 1 { t.Fatalf("rows = %+v", rows) } r0, r1 := rows[0], rows[1] if !r0.IsEnabled || !r0.IsDefault || r0.GeneratesImages || !r0.AcceptsImages || r0.HasFilesEndpoint || r0.MaxCompletionTokens == nil || *r0.MaxCompletionTokens != 4096 || r0.BaseURL == nil || *r0.BaseURL != "https://api.openai.com/v1" || r0.SystemPrompt != nil { t.Fatalf("row 0 = %+v", r0) } if r1.Adapter != "anthropic" || r1.IsEnabled || r1.BaseURL != nil || r1.SystemPrompt == nil || *r1.SystemPrompt != "Be terse." || *r1.MaxCompletionTokens != 8192 { t.Fatalf("row 1 = %+v", r1) } if r0.APIKey.Reveal() != plain { t.Fatal("the imported key does not decrypt to the plaintext") } var stored string if err := db.Raw(`SELECT api_key FROM golem15_golem_models WHERE name = 'Chat'`).Scan(&stored).Error; err != nil { t.Fatal(err) } if stored == "" || strings.Contains(stored, plain) || strings.Contains(stored, "sk-") { t.Fatalf("the key is stored in plaintext: %q", stored) } if _, err := console.ImportModels(ctx, db, ""); !errors.Is(err, console.ErrModelsNotEmpty) { t.Fatalf("second run = %v", err) } var count int64 db.Model(&models.AiModel{}).Count(&count) if count != 2 { t.Fatalf("second run wrote rows: %d", count) } // --file reads a JSON file instead (a bare models list here). if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { t.Fatal(err) } file := filepath.Join(t.TempDir(), "golem_settings.json") if err := os.WriteFile(file, []byte(`[{"name":"Local","model":"llama3","api_key":"local-key","base_url":"http://10.0.0.5:11434/v1","is_enabled":true}]`), 0o600); err != nil { t.Fatal(err) } if n, err := console.ImportModels(ctx, db, file); err != nil || n != 1 { t.Fatalf("file import = %d, %v", n, err) } var local models.AiModel if err := db.Take(&local).Error; err != nil || local.Adapter != "openai" || local.APIKey.Reveal() != "local-key" { t.Fatalf("file row = %+v, %v", local, err) } if _, err := console.ImportModels(ctx, db, filepath.Join(t.TempDir(), "missing.json")); err == nil { t.Fatal("a missing file must fail") } } // TestAdminModelsForm compiles the AI models list and form and saves // through cabana's CRUD service: the key is required on create, shown // redacted, kept when an update sends it back redacted or empty, and // replaced by a new value. func TestAdminModelsForm(t *testing.T) { ctx := context.Background() db := pgtest.DB(t) if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { t.Fatal(err) } p := &Plugin{} ctls := p.AdminControllers() if len(ctls) != 1 || ctls[0].ID() != "golem15.golem.models" { t.Fatalf("controllers = %v", ctls) } ctl := ctls[0] if _, err := cabana.CompileList("golem15.golem", ctl, p.AdminFS()); err != nil { t.Fatalf("list: %v", err) } form, err := cabana.CompileForm("golem15.golem", ctl, p.AdminFS()) if err != nil { t.Fatalf("form: %v", err) } cc := &cabana.CompiledController{PluginID: "golem15.golem", Controller: ctl, Form: form} if err := cabana.BindWritableFields(cc); err != nil { t.Fatal(err) } if perms := p.Permissions(); len(perms) != 1 || perms[0].Code != "golem15.golem.access_settings" { t.Fatalf("permissions = %+v", perms) } if nav := p.Navigation(); len(nav) != 1 || nav[0].SideMenu[0].Controller != "golem15.golem.models" { t.Fatalf("navigation = %+v", nav) } svc := cabana.CRUDService{DB: db} body := map[string]any{"name": "Vision", "adapter": "anthropic", "model": "claude-sonnet-4-6", "is_enabled": true, "accepts_images": true} _, err = svc.Create(ctx, cc, cabana.RecordInput{Body: body}) var ve *cabana.ValidationError if !errors.As(err, &ve) || ve.Details["api_key"] == nil { t.Fatalf("create without a key = %v", err) } body["api_key"] = "sk-ant-admin-0001" rec, err := svc.Create(ctx, cc, cabana.RecordInput{Body: body}) if err != nil { t.Fatal(err) } raw, _ := json.Marshal(rec) if strings.Contains(string(raw), "sk-ant-admin-0001") { t.Fatalf("the record echoes the key: %s", raw) } id := rec["id"] keyOf := func() string { var m models.AiModel if err := db.Where("id = ?", id).Take(&m).Error; err != nil { t.Fatal(err) } return m.APIKey.Reveal() } if keyOf() != "sk-ant-admin-0001" { t.Fatal("created key") } for _, sent := range []any{"[redacted]", ""} { body["api_key"] = sent body["model"] = "claude-opus" if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil { t.Fatalf("update with %q: %v", sent, err) } if keyOf() != "sk-ant-admin-0001" { t.Fatalf("an update with %q replaced the key", sent) } } body["api_key"] = "sk-ant-admin-0002" if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil { t.Fatal(err) } if keyOf() != "sk-ant-admin-0002" { t.Fatal("a new key was not stored") } // A kept key never follows a changed endpoint: changing the base URL // or the adapter without re-entering the key is refused (WR-04). for field, value := range map[string]any{"base_url": "https://attacker.example/v1", "adapter": "openai"} { changed := maps.Clone(body) changed["api_key"] = "[redacted]" changed[field] = value _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: changed}) if !errors.As(err, &ve) || ve.Details["api_key"] == nil { t.Fatalf("a %s change with the redacted key = %v", field, err) } if keyOf() != "sk-ant-admin-0002" { t.Fatalf("the refused %s change touched the key", field) } changed["api_key"] = "sk-ant-admin-0003" if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: changed}); err != nil { t.Fatalf("a %s change with a new key: %v", field, err) } if keyOf() != "sk-ant-admin-0003" { t.Fatalf("the %s change did not store the new key", field) } restore := maps.Clone(body) restore["base_url"] = "" if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: restore}); err != nil { t.Fatalf("restoring the endpoint: %v", err) } } body["adapter"] = "gemini" if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); !errors.As(err, &ve) { t.Fatalf("unknown adapter = %v", err) } }