package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/s3client" ) type fakeServerSettingsStore struct { values map[string]string } 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 { if f.values == nil { f.values = map[string]string{} } f.values[key] = value return nil } 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 } func (f *fakeS3SettingsCheckClient) HeadBucket(ctx context.Context, bucket string) error { if f.headBucket != nil { return f.headBucket(ctx, bucket) } return nil } 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) } 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 } 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 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} 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 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)) }