1629 lines
50 KiB
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))
|
|
}
|