Files
silo-server/internal/api/handlers/admin_settings_checks_test.go

1629 lines
50 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/s3client"
)
type fakeServerSettingsStore struct {
values map[string]string
setCalls int
setManyCalls int
atomicCalls int
}
func (f *fakeServerSettingsStore) Get(_ context.Context, key string) (string, error) {
return f.values[key], nil
}
func (f *fakeServerSettingsStore) Set(_ context.Context, key, value string) error {
f.setCalls++
if f.values == nil {
f.values = map[string]string{}
}
f.values[key] = value
return nil
}
func (f *fakeServerSettingsStore) SetMany(_ context.Context, values map[string]string) error {
f.setManyCalls++
if f.values == nil {
f.values = map[string]string{}
}
for key, value := range values {
f.values[key] = value
}
return nil
}
func (f *fakeServerSettingsStore) UpdateAtomic(
ctx context.Context,
update func(current map[string]string) (map[string]string, error),
) error {
f.atomicCalls++
current, err := f.GetAll(ctx)
if err != nil {
return err
}
writes, err := update(current)
if err != nil || len(writes) == 0 {
return err
}
return f.SetMany(ctx, writes)
}
func TestAdminGetEffectiveSettingsReturnsRuntimeDefaultsAndRedactsSecrets(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
"server.log_level": "debug",
"tmdb.api_key": "never-return-this",
}}
handler := &AdminHandler{SettingsRepo: settings}
rec := httptest.NewRecorder()
handler.HandleGetEffectiveSettings(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/effective", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var values map[string]string
if err := json.NewDecoder(rec.Body).Decode(&values); err != nil {
t.Fatalf("decode response: %v", err)
}
if values["server.log_level"] != "debug" {
t.Fatalf("server.log_level = %q, want debug", values["server.log_level"])
}
if values["database.max_connections"] != "20" {
t.Fatalf("database.max_connections = %q, want effective default 20", values["database.max_connections"])
}
if values["playback.transcode_enabled"] != "true" {
t.Fatalf("playback.transcode_enabled = %q, want effective default true", values["playback.transcode_enabled"])
}
if _, leaked := values["tmdb.api_key"]; leaked {
t.Fatal("effective settings response leaked tmdb.api_key")
}
}
func TestAdminGetEffectiveSettingsUsesEnvironmentManagedRuntimeValue(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
"clientip.trusted_proxies": "10.0.0.0/8",
}}
handler := &AdminHandler{
SettingsRepo: settings,
BootstrapSensitiveConfigured: map[string]bool{
"clientip.trusted_proxies": true,
"redis.url": true,
},
BootstrapSensitiveValues: map[string]string{
"clientip.trusted_proxies": "192.0.2.0/24, 2001:db8::/32",
"redis.url": "redis://private.example.invalid:6379",
},
}
rec := httptest.NewRecorder()
handler.HandleGetEffectiveSettings(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/effective", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var values map[string]string
if err := json.NewDecoder(rec.Body).Decode(&values); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := values["clientip.trusted_proxies"]; got != "192.0.2.0/24, 2001:db8::/32" {
t.Fatalf("clientip.trusted_proxies = %q, want active environment value", got)
}
if _, leaked := values["redis.url"]; leaked {
t.Fatal("effective settings response leaked environment-managed redis.url")
}
}
func TestAdminSettingsValidationIncludesEnvironmentManagedValues(t *testing.T) {
newHandler := func() (*AdminHandler, *fakeServerSettingsStore) {
settings := &fakeServerSettingsStore{values: map[string]string{
"watchsync.trakt.client_id": "configured-client-id",
}}
return &AdminHandler{
SettingsRepo: settings,
BootstrapSensitiveConfigured: map[string]bool{
"watchsync.trakt.client_secret": true,
},
BootstrapSensitiveValues: map[string]string{
"watchsync.trakt.client_secret": "clawrouter-e2e-secret",
},
}, settings
}
t.Run("batch update", func(t *testing.T) {
handler, settings := newHandler()
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"branding.server_name":"Casa"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values["branding.server_name"] != "Casa" {
t.Fatalf("branding.server_name = %q, want Casa", settings.values["branding.server_name"])
}
})
t.Run("single update", func(t *testing.T) {
handler, settings := newHandler()
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/branding.server_name",
strings.NewReader(`{"value":"Casa"}`),
)
req = withChiParam(req, "key", "branding.server_name")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values["branding.server_name"] != "Casa" {
t.Fatalf("branding.server_name = %q, want Casa", settings.values["branding.server_name"])
}
})
}
func TestAdminSensitiveStatusReportsNonSecretEnvironmentManagedSettings(t *testing.T) {
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{values: map[string]string{}},
BootstrapSensitiveConfigured: map[string]bool{
"clientip.trusted_proxies": true,
},
}
rec := httptest.NewRecorder()
handler.HandleGetSensitiveStatus(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/sensitive-status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response sensitiveStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.ManagedByEnv) != 1 || response.ManagedByEnv[0] != "clientip.trusted_proxies" {
t.Fatalf("managed_by_env = %#v", response.ManagedByEnv)
}
}
func TestAdminUpdateSettingsRejectsEnvironmentManagedSetting(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{
SettingsRepo: settings,
BootstrapSensitiveConfigured: map[string]bool{
"clientip.trusted_proxies": true,
},
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"clientip.trusted_proxies":"10.0.0.0/8"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 {
t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls)
}
}
func TestAdminUpdateSettingsCommitsOneValidatedBatch(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"database.max_connections":" 40 ","branding.server_name":"Casa"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 1 {
t.Fatalf("SetMany calls = %d, want 1", settings.setManyCalls)
}
if settings.values["database.max_connections"] != "40" ||
settings.values["branding.server_name"] != "Casa" {
t.Fatalf("stored values = %#v", settings.values)
}
var response updateSettingsResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if !response.RestartRequired ||
len(response.RestartRequiredKeys) != 1 ||
response.RestartRequiredKeys[0] != "database.max_connections" {
t.Fatalf("response = %#v", response)
}
}
func TestAdminUpdateSettingsRejectsWholeBatchBeforeWrite(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"branding.server_name":"Casa","database.max_connections":"0"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 {
t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls)
}
if settings.values["branding.server_name"] != "Silo" {
t.Fatalf("valid sibling value was partially persisted: %#v", settings.values)
}
}
func TestAdminUpdateSettingsValidatesProspectiveDiagnosticsLimits(t *testing.T) {
const (
mib = 1024 * 1024
)
t.Run("rejects invalid final relationship", func(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
diagnostics.KeyMaxBundleBytes: strconv.Itoa(10 * mib),
diagnostics.KeyMaxUncompressedBytes: strconv.Itoa(64 * mib),
diagnostics.KeyMaxBytesPerUser: strconv.Itoa(200 * mib),
}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(fmt.Sprintf(
`{"values":{"%s":"%d","%s":"%d"}}`,
diagnostics.KeyMaxBundleBytes,
50*mib,
diagnostics.KeyMaxUncompressedBytes,
20*mib,
)),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 {
t.Fatalf("invalid diagnostics batch wrote settings: SetMany=%d", settings.setManyCalls)
}
})
t.Run("accepts valid paired repair", func(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
diagnostics.KeyMaxBundleBytes: strconv.Itoa(64 * mib),
diagnostics.KeyMaxUncompressedBytes: strconv.Itoa(128 * mib),
diagnostics.KeyMaxBytesPerUser: strconv.Itoa(200 * mib),
}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(fmt.Sprintf(
`{"values":{"%s":"%d","%s":"%d"}}`,
diagnostics.KeyMaxBundleBytes,
50*mib,
diagnostics.KeyMaxUncompressedBytes,
60*mib,
)),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 1 {
t.Fatalf("valid diagnostics batch SetMany=%d, want 1", settings.setManyCalls)
}
})
}
func TestAdminGenericSettingsRoutesRejectUnsafeRateLimitValues(t *testing.T) {
t.Run("batch", func(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"ratelimit.global.requests_per_second":"1e308"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 || settings.setCalls != 0 {
t.Fatalf("invalid rate wrote settings: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls)
}
})
t.Run("single", func(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/ratelimit.global.requests_per_second",
strings.NewReader(`{"value":"1e308"}`),
)
req = withChiParam(req, "key", "ratelimit.global.requests_per_second")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 || settings.setCalls != 0 {
t.Fatalf("invalid rate wrote settings: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls)
}
})
}
func TestAdminSettingsRejectClearingOnlyRedisTransport(t *testing.T) {
newStore := func() *fakeServerSettingsStore {
return &fakeServerSettingsStore{values: map[string]string{
"ratelimit.backend": "redis",
"redis.url": "redis://cache.example.invalid:6379",
}}
}
t.Run("batch", func(t *testing.T) {
settings := newStore()
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"redis.url":""}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 || settings.values["redis.url"] == "" {
t.Fatalf("invalid clear was persisted: calls=%d values=%#v", settings.setManyCalls, settings.values)
}
})
t.Run("single", func(t *testing.T) {
settings := newStore()
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/redis.url",
strings.NewReader(`{"value":""}`),
)
req = withChiParam(req, "key", "redis.url")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setCalls != 0 || settings.values["redis.url"] == "" {
t.Fatalf("invalid clear was persisted: calls=%d values=%#v", settings.setCalls, settings.values)
}
})
t.Run("bootstrap Sentinel", func(t *testing.T) {
settings := newStore()
handler := &AdminHandler{
SettingsRepo: settings,
RedisBootstrapAvailable: true,
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"redis.url":""}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 1 || settings.values["redis.url"] != "" {
t.Fatalf("clear with bootstrap transport was not persisted: calls=%d values=%#v", settings.setManyCalls, settings.values)
}
})
}
func TestAdminSettingsRejectMalformedRedisURL(t *testing.T) {
for _, tc := range []struct {
name string
target string
body string
single bool
}{
{name: "batch", target: "/admin/settings", body: `{"values":{"redis.url":"not-a-url"}}`},
{name: "single", target: "/admin/settings/redis.url", body: `{"value":"not-a-url"}`, single: true},
} {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(http.MethodPut, tc.target, strings.NewReader(tc.body))
if tc.single {
req = withChiParam(req, "key", "redis.url")
}
rec := httptest.NewRecorder()
if tc.single {
handler.HandleUpdateSetting(rec, req)
} else {
handler.HandleUpdateSettings(rec, req)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 || settings.setCalls != 0 {
t.Fatalf("malformed Redis URL was persisted: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls)
}
})
}
}
func TestAdminUpdateSettingsSkipsFunctionalNoOp(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"database.max_connections":"20"}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 0 {
t.Fatalf("SetMany calls = %d, want 0 for an effective-default no-op", settings.setManyCalls)
}
var response updateSettingsResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RestartRequired || len(response.RestartRequiredKeys) != 0 {
t.Fatalf("no-op response requested restart: %#v", response)
}
}
func TestAdminUpdateSettingsPersistsClearWhenOverrideEqualsDefault(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}}
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"branding.server_name":""}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 1 {
t.Fatalf("SetMany calls = %d, want 1 for explicit clear", settings.setManyCalls)
}
if settings.values["branding.server_name"] != "" {
t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"])
}
var response updateSettingsResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Values["branding.server_name"] != "Silo" || response.RestartRequired {
t.Fatalf("response = %#v, want unchanged effective default without restart", response)
}
}
func TestAdminUpdateSettingsReturnsEffectiveDefaultAfterClear(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Casa"}}
var callbackValue string
handler := &AdminHandler{
SettingsRepo: settings,
OnServerSettingUpdated: func(_ context.Context, _ string, value string) {
callbackValue = value
},
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings",
strings.NewReader(`{"values":{"branding.server_name":""}}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response updateSettingsResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if response.Values["branding.server_name"] != "Silo" {
t.Fatalf("response values = %#v, want effective default", response.Values)
}
if settings.values["branding.server_name"] != "" {
t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"])
}
if callbackValue != "Silo" {
t.Fatalf("callback value = %q, want effective default Silo", callbackValue)
}
}
func TestAdminUpdateSettingReturnsEffectiveDefaultAfterClear(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Casa"}}
var callbackValue string
handler := &AdminHandler{
SettingsRepo: settings,
OnServerSettingUpdated: func(_ context.Context, _ string, value string) {
callbackValue = value
},
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/branding.server_name",
strings.NewReader(`{"value":""}`),
)
req = withChiParam(req, "key", "branding.server_name")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if response.Value != "Silo" {
t.Fatalf("response value = %q, want effective default", response.Value)
}
if callbackValue != "Silo" {
t.Fatalf("callback value = %q, want effective default Silo", callbackValue)
}
}
func TestAdminUpdateSettingPersistsClearWhenOverrideEqualsDefault(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/branding.server_name",
strings.NewReader(`{"value":""}`),
)
req = withChiParam(req, "key", "branding.server_name")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.setManyCalls != 1 {
t.Fatalf("atomic writes = %d, want 1 for explicit clear", settings.setManyCalls)
}
if settings.values["branding.server_name"] != "" {
t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"])
}
var response adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Value != "Silo" || response.RestartRequired {
t.Fatalf("response = %#v, want unchanged effective default without restart", response)
}
}
func TestAdminUpdateSettingPreservesLegacyPairedWriteFlow(t *testing.T) {
for _, tc := range []struct {
name string
initial map[string]string
key string
value string
}{
{
name: "establish first half of pair",
key: "s3.public_endpoint",
value: "https://s3.example.invalid",
},
{
name: "unrelated update with legacy partial pair",
initial: map[string]string{
"s3.public_endpoint": "https://s3.example.invalid",
},
key: "branding.server_name",
value: "Casa",
},
{
name: "clear first half of pair",
initial: map[string]string{
"watchsync.trakt.client_id": "configured-client-id",
"watchsync.trakt.client_secret": "clawrouter-e2e-secret",
},
key: "watchsync.trakt.client_id",
value: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: tc.initial}
if settings.values == nil {
settings.values = map[string]string{}
}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.value {
t.Fatalf("stored %s = %q, want %q", tc.key, settings.values[tc.key], tc.value)
}
})
}
}
func (f *fakeServerSettingsStore) GetAll(context.Context) (map[string]string, error) {
cloned := make(map[string]string, len(f.values))
for key, value := range f.values {
cloned[key] = value
}
return cloned, nil
}
type fakeS3SettingsCheckClient struct {
headBucket func(ctx context.Context, bucket string) error
putObject func(ctx context.Context, bucket, key string, data []byte) error
getObject func(ctx context.Context, bucket, key string) ([]byte, error)
delete func(ctx context.Context, bucket, key string) error
objects map[string][]byte
}
func (f *fakeS3SettingsCheckClient) PutObject(
ctx context.Context,
bucket,
key string,
data []byte,
) error {
if f.putObject != nil {
return f.putObject(ctx, bucket, key, data)
}
if f.objects == nil {
f.objects = make(map[string][]byte)
}
f.objects[key] = append([]byte(nil), data...)
return nil
}
func (f *fakeS3SettingsCheckClient) GetObject(
ctx context.Context,
bucket,
key string,
) ([]byte, error) {
if f.getObject != nil {
return f.getObject(ctx, bucket, key)
}
return append([]byte(nil), f.objects[key]...), nil
}
func (f *fakeS3SettingsCheckClient) DeleteObject(ctx context.Context, bucket, key string) error {
if f.delete != nil {
return f.delete(ctx, bucket, key)
}
delete(f.objects, key)
return nil
}
func (f *fakeS3SettingsCheckClient) HeadBucket(ctx context.Context, bucket string) error {
if f.headBucket != nil {
return f.headBucket(ctx, bucket)
}
return nil
}
func TestCheckS3ObjectPermissionsCleansUpAfterAmbiguousPutFailure(t *testing.T) {
client := &fakeS3SettingsCheckClient{}
client.putObject = func(_ context.Context, _, key string, data []byte) error {
if client.objects == nil {
client.objects = make(map[string][]byte)
}
client.objects[key] = append([]byte(nil), data...)
return errors.New("response lost")
}
err := checkS3ObjectPermissions(context.Background(), client, "silo")
if err == nil || !strings.Contains(err.Error(), "write probe object: response lost") {
t.Fatalf("error = %v, want ambiguous write failure", err)
}
if len(client.objects) != 0 {
t.Fatalf("probe objects = %#v, want ambiguous write cleaned up", client.objects)
}
}
func TestCheckS3ObjectPermissionsUsesFreshContextForFailureCleanup(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var cleanupContextErr error
client := &fakeS3SettingsCheckClient{
getObject: func(context.Context, string, string) ([]byte, error) {
cancel()
return nil, context.Canceled
},
delete: func(ctx context.Context, _, _ string) error {
cleanupContextErr = ctx.Err()
return nil
},
}
err := checkS3ObjectPermissions(ctx, client, "silo")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context cancellation", err)
}
if cleanupContextErr != nil {
t.Fatalf("cleanup context error = %v, want live cleanup context", cleanupContextErr)
}
}
func TestCheckS3ObjectPermissionsSurfacesCleanupFailure(t *testing.T) {
client := &fakeS3SettingsCheckClient{
getObject: func(context.Context, string, string) ([]byte, error) {
return nil, errors.New("read failed")
},
delete: func(context.Context, string, string) error {
return errors.New("delete failed")
},
}
err := checkS3ObjectPermissions(context.Background(), client, "silo")
if err == nil || !strings.Contains(err.Error(), "cleanup probe object: delete failed") {
t.Fatalf("error = %v, want cleanup failure", err)
}
if !strings.Contains(err.Error(), "read probe object: read failed") {
t.Fatalf("error = %v, want original read failure", err)
}
}
type fakeRedisSettingsCheckClient struct {
ping func(ctx context.Context) error
}
func (f *fakeRedisSettingsCheckClient) Ping(ctx context.Context) error {
if f.ping != nil {
return f.ping(ctx)
}
return nil
}
func (f *fakeRedisSettingsCheckClient) Close() error {
return nil
}
type fakeEmbeddingsSettingsCheckClient struct {
embed func(ctx context.Context, texts []string) ([][]float32, error)
}
type fakeMDBListSettingsCheckClient struct {
check func(context.Context) error
}
func (f *fakeMDBListSettingsCheckClient) Check(ctx context.Context) error {
if f.check != nil {
return f.check(ctx)
}
return nil
}
func (f *fakeEmbeddingsSettingsCheckClient) Embed(
ctx context.Context,
texts []string,
) ([][]float32, error) {
if f.embed != nil {
return f.embed(ctx, texts)
}
return [][]float32{{0.1, 0.2}}, nil
}
type fakeAISettingsCheckClient struct {
chat func(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
transcribe func(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
func (f *fakeAISettingsCheckClient) Chat(
ctx context.Context,
messages []llm.Message,
jsonObject bool,
) (string, error) {
if f.chat != nil {
return f.chat(ctx, messages, jsonObject)
}
return `{"status":"ok"}`, nil
}
func (f *fakeAISettingsCheckClient) Transcribe(
ctx context.Context,
req llm.TranscribeRequest,
) (*llm.Transcription, error) {
if f.transcribe != nil {
return f.transcribe(ctx, req)
}
return &llm.Transcription{}, nil
}
func TestHandleCheckSettingsConnectionAIChatDoesNotSendStoredKeyToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var chatCalled bool
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
chat: func(_ context.Context, _ []llm.Message, jsonObject bool) (string, error) {
chatCalled = true
if !jsonObject {
t.Fatal("chat connection check did not request a JSON response")
}
return `{"status":"ok"}`, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://draft.example.test",
"ai.chat_model": "draft-model",
"ai.api_key": "",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success || !chatCalled {
t.Fatalf("response = %+v, chatCalled = %v", response, chatCalled)
}
if captured.BaseURL != "https://draft.example.test" || captured.ChatModel != "draft-model" {
t.Fatalf("captured config = %+v, want draft endpoint and model", captured)
}
if captured.APIKey != "" {
t.Fatalf("captured API key = %q, want no stored secret for a changed endpoint", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAIChatReusesStoredKeyForSameEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test/v1",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://persisted.example.test/other-path",
"ai.chat_model": "draft-model",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.APIKey != "persisted-secret" {
t.Fatalf("captured API key = %q, want stored secret for unchanged authority", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionUsesDedicatedASR(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var request llm.TranscribeRequest
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
transcribe: func(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) {
request = req
return &llm.Transcription{}, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{"values": map[string]string{}, "dirty_keys": []string{}},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success {
t.Fatalf("response.Success = false, message = %q", response.Message)
}
if captured.ASRBaseURL != "https://whisper.example.test" ||
captured.ASRAPIKey != "asr-secret" ||
captured.ASRModel != "whisper-model" {
t.Fatalf("captured ASR config = %+v", captured)
}
if len(request.Audio) == 0 || request.Filename == "" || request.Timeout <= 0 {
t.Fatalf("transcription probe request = %+v, want bounded WAV probe", request)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionDoesNotSendStoredKeysToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://stored-whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{
"values": map[string]string{
"ai.asr_base_url": "https://draft-whisper.example.test",
},
"dirty_keys": []string{"ai.asr_base_url"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.ASRAPIKey != "" || captured.APIKey != "" {
t.Fatalf("captured config = %+v, want no stored ASR or inherited chat secret", captured)
}
}
func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) {
originalFactory := newAdminS3SettingsCheckClient
t.Cleanup(func() {
newAdminS3SettingsCheckClient = originalFactory
})
var captured s3client.BucketConfig
newAdminS3SettingsCheckClient = func(cfg s3client.BucketConfig) s3SettingsCheckClient {
captured = cfg
return &fakeS3SettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"s3.public_endpoint": "https://persisted.example.test",
"s3.public_bucket": "silo",
"s3.public_key_prefix": "persisted/prefix",
"s3.public_access_key": "persisted-access",
"s3.public_secret_key": "persisted-secret",
},
},
}
body := map[string]any{
"values": map[string]string{
"s3.public_endpoint": "https://draft.example.test",
"s3.public_access_key": "",
"s3.public_secret_key": "",
},
"dirty_keys": []string{"s3.public_endpoint"},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/s3_public",
body,
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success {
t.Fatalf("response.Success = false, want true (message=%q)", response.Message)
}
if captured.Endpoint != "https://draft.example.test" {
t.Fatalf("captured endpoint = %q, want draft endpoint", captured.Endpoint)
}
if captured.KeyPrefix != "persisted/prefix" {
t.Fatalf("captured key prefix = %q, want persisted/prefix", captured.KeyPrefix)
}
if captured.AccessKey != "persisted-access" {
t.Fatalf("captured access key = %q, want persisted-access", captured.AccessKey)
}
if captured.SecretKey != "persisted-secret" {
t.Fatalf("captured secret key = %q, want persisted-secret", captured.SecretKey)
}
}
func TestHandleCheckSettingsConnectionMDBListUsesDraftOrSavedKey(t *testing.T) {
originalFactory := newAdminMDBListSettingsCheckClient
t.Cleanup(func() { newAdminMDBListSettingsCheckClient = originalFactory })
var captured []string
newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsCheckClient {
captured = append(captured, apiKey)
return &fakeMDBListSettingsCheckClient{}
}
handler := &AdminHandler{SettingsRepo: &fakeServerSettingsStore{values: map[string]string{
"mdblist.api_key": "saved-key",
}}}
for _, tc := range []struct {
name string
body map[string]any
want string
}{
{
name: "saved key when draft is blank and untouched",
body: map[string]any{"values": map[string]string{"mdblist.api_key": ""}, "dirty_keys": []string{}},
want: "saved-key",
},
{
name: "unsaved draft key",
body: map[string]any{"values": map[string]string{"mdblist.api_key": "draft-key"}, "dirty_keys": []string{"mdblist.api_key"}},
want: "draft-key",
},
} {
t.Run(tc.name, func(t *testing.T) {
rec := performSettingsCheckRequest(t, handler, "/admin/settings/check/mdblist", tc.body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if !response.Success {
t.Fatalf("connection check failed: %s", response.Message)
}
if got := captured[len(captured)-1]; got != tc.want {
t.Fatalf("factory API key = %q, want %q", got, tc.want)
}
})
}
}
func TestHandleCheckSettingsConnectionRedisHonorsExplicitClear(t *testing.T) {
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"redis.url": "redis://persisted:6379",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/redis",
map[string]any{
"values": map[string]string{
"redis.url": "",
},
"dirty_keys": []string{"redis.url"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if response.Success {
t.Fatalf("response.Success = true, want false")
}
if !strings.Contains(response.Message, "Redis URL is required") {
t.Fatalf("message = %q, want Redis URL validation", response.Message)
}
}
func TestHandleCheckSettingsConnectionRejectsInvalidDraftValues(t *testing.T) {
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"s3.public_endpoint": "https://persisted.example.test",
"s3.public_bucket": "silo",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/s3_public",
map[string]any{
"values": map[string]string{
"s3.public_token_ttl": "not-a-number",
},
"dirty_keys": []string{"s3.public_token_ttl"},
},
)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
var response map[string]string
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !strings.Contains(response["message"], "invalid int for") {
t.Fatalf("message = %q, want parse failure", response["message"])
}
}
func TestSettingsCheckRouteIsNotShadowedByKeyRoute(t *testing.T) {
originalFactory := newAdminRedisSettingsCheckClient
t.Cleanup(func() {
newAdminRedisSettingsCheckClient = originalFactory
})
newAdminRedisSettingsCheckClient = func(cfg config.RedisConfig) (redisSettingsCheckClient, error) {
return &fakeRedisSettingsCheckClient{}, nil
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"redis.url": "redis://cache:6379",
},
},
}
router := chi.NewRouter()
router.Post("/admin/settings/check/{kind}", handler.HandleCheckSettingsConnection)
router.Get("/admin/settings/{key}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
body, err := json.Marshal(map[string]any{
"values": map[string]string{
"redis.url": "redis://cache:6379",
},
"dirty_keys": []string{"redis.url"},
})
if err != nil {
t.Fatalf("Marshal() returned error: %v", err)
}
req := httptest.NewRequest(
http.MethodPost,
"/admin/settings/check/redis",
bytes.NewReader(body),
)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestAdminGetSettingRedactsSensitiveSetting(t *testing.T) {
for _, key := range []string{"watchsync.trakt.client_secret", "watchsync.simkl.client_secret"} {
t.Run(key, func(t *testing.T) {
const storedSecret = "stored-watch-provider-secret"
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
key: storedSecret,
},
},
}
req := httptest.NewRequest(http.MethodGet, "/admin/settings/"+key, nil)
req = withChiParam(req, "key", key)
rec := httptest.NewRecorder()
handler.HandleGetSetting(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), storedSecret) {
t.Fatalf("response leaked sensitive value: %s", rec.Body.String())
}
})
}
}
func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
for _, key := range []string{"watchsync.trakt.client_secret", "watchsync.simkl.client_secret"} {
t.Run(key, func(t *testing.T) {
const submittedSecret = "submitted-watch-provider-secret"
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
providerPrefix := strings.TrimSuffix(key, ".client_secret")
settings.values = map[string]string{providerPrefix + ".client_id": "configured-client-id"}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+key,
strings.NewReader(`{"value":"`+submittedSecret+`"}`),
)
req = withChiParam(req, "key", key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[key] != submittedSecret {
t.Fatalf("stored value = %q, want submitted secret", settings.values[key])
}
if strings.Contains(rec.Body.String(), submittedSecret) {
t.Fatalf("response leaked sensitive value: %s", rec.Body.String())
}
var resp adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Key != key || resp.Value != "" {
t.Fatalf("response = %+v, want key with empty value", resp)
}
})
}
}
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
cases := []struct {
key string
value string
restartRequired bool
}{
// Infrastructure settings are captured at startup.
{key: "database.max_connections", value: "40", restartRequired: true},
{key: "s3.public_bucket", value: "assets", restartRequired: true},
{key: "jellyfin_compat.enabled", value: "false", restartRequired: true},
// Branding is read live from the settings repo per request.
{key: "branding.server_name", value: "Casa", restartRequired: false},
{key: "policy.editor_enabled", value: "true", restartRequired: false},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
initial := map[string]string{}
if tc.key == "s3.public_bucket" {
initial["s3.public_endpoint"] = "https://s3.example.test"
}
settings := &fakeServerSettingsStore{values: initial}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired != tc.restartRequired {
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
}
snapshot := restartStatus.Snapshot()
if snapshot.RestartRequired != tc.restartRequired {
t.Fatalf("tracker restart required = %v, want %v", snapshot.RestartRequired, tc.restartRequired)
}
if !tc.restartRequired && snapshot.RestartRequiredReason != "" {
t.Fatalf("tracker reason = %q, want empty", snapshot.RestartRequiredReason)
}
})
}
}
func TestAdminUpdateSettingNormalizesAIEndpointAndModelValues(t *testing.T) {
for _, tc := range []struct {
key string
value string
want string
}{
{key: "ai.base_url", value: " https://text.example.test/v1 ", want: "https://text.example.test/v1"},
{key: "ai.chat_model", value: " chat-model ", want: "chat-model"},
{key: "ai.asr_base_url", value: " https://speech.example.test ", want: "https://speech.example.test"},
{key: "ai.asr_model", value: " whisper-model ", want: "whisper-model"},
} {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
body, err := json.Marshal(updateSettingRequest{Value: tc.value})
if err != nil {
t.Fatalf("Marshal() returned error: %v", err)
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
bytes.NewReader(body),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.want {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
}
})
}
}
func TestAdminUpdatePolicyEditorEnabledValidation(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/policy.editor_enabled",
strings.NewReader(`{"value":"maybe"}`),
)
req = withChiParam(req, "key", "policy.editor_enabled")
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, ok := settings.values["policy.editor_enabled"]; ok {
t.Fatalf("invalid policy.editor_enabled was stored: %#v", settings.values)
}
}
func TestAdminUpdateCatalogSearchSemanticSettingsValidation(t *testing.T) {
valid := []struct {
key string
value string
want string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "TRUE", want: "true"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: ".30", want: "0.3"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "", want: catalog.DefaultMeilisearchEmbedder},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "custom_embedder-2", want: "custom_embedder-2"},
}
for _, tc := range valid {
t.Run("valid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got := config.EffectiveAdminSettings(settings.values)[tc.key]; got != tc.want {
t.Fatalf("effective value = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
key string
value string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "sometimes"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "-0.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "1.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "NaN"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "bad.name"},
}
for _, tc := range invalid {
t.Run("invalid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, ok := settings.values[tc.key]; ok {
t.Fatalf("invalid setting was stored: %#v", settings.values)
}
})
}
}
func performSettingsCheckRequest(
t *testing.T,
handler *AdminHandler,
path string,
body map[string]any,
) *httptest.ResponseRecorder {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("Marshal() returned error: %v", err)
}
router := chi.NewRouter()
router.Post("/admin/settings/check/{kind}", handler.HandleCheckSettingsConnection)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func withChiParam(r *http.Request, key, value string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
}