An admin who cannot read the write-only key could point base_url at their own host, leave the key redacted and have it sent there on the next call. An update that keeps the stored key may no longer change the adapter or base URL; changing either needs the key re-entered.
282 lines
11 KiB
Go
282 lines
11 KiB
Go
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)
|
|
}
|
|
}
|