Files
sm-golem-plugin/golem_test.go
Jakub Zych 93b2b05476 fix(14): WR-04 require the AI key again when the endpoint changes
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.
2026-10-04 08:08:07 +02:00

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)
}
}