356 lines
9.4 KiB
Go
356 lines
9.4 KiB
Go
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))
|
|
}
|