Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #83.
This commit is contained in:
@@ -35,6 +35,8 @@ const (
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
|
||||
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -73,6 +75,13 @@ type Config struct {
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
|
||||
// SearXNGURL is the deployment's SearXNG instance, which the Settings
|
||||
// screen suggests while no recipe-search URL is saved. It is optional
|
||||
// and validated by recipes.NewSearchSettingsService: the compose
|
||||
// network's http://searxng:8080 is neither https nor loopback, so the
|
||||
// secure-URL rule does not apply. Empty when unset.
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
|
||||
SearXNGURL: r.optional(EnvSearXNGURL),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
|
||||
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
if cfg.SearXNGURL != "http://searxng:8080" {
|
||||
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
|
||||
type SearchSettings struct {
|
||||
// Provider is "searxng" or "brave", nil until first chosen.
|
||||
Provider *string `json:"provider"`
|
||||
// BaseURL is the SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// BaseURL is the saved SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
// DefaultBaseURL is the deployment's SearXNG URL
|
||||
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
|
||||
// not set. It is never saved by itself: only a PATCH of base_url saves
|
||||
// a URL. Nil when the deployment names none.
|
||||
DefaultBaseURL *string `json:"default_base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// UpdatedAt is nil until the settings are first saved.
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
|
||||
type SearchSettingsService struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
|
||||
defaultBaseURL string
|
||||
// newProvider is NewSearchProvider; tests point Brave elsewhere.
|
||||
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
|
||||
}
|
||||
|
||||
// NewSearchSettingsService returns the service over st, sealing keys with
|
||||
// box.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
|
||||
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
|
||||
// one must be a valid SearXNG URL.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
}
|
||||
if box == nil {
|
||||
return nil, errors.New("recipes: nil secret box")
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
|
||||
if defaultBaseURL != "" {
|
||||
if _, err := NewSearXNG(defaultBaseURL); err != nil {
|
||||
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
|
||||
}
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings; before any are saved every field is unset.
|
||||
// Get returns the settings; before any are saved every field but
|
||||
// DefaultBaseURL is unset.
|
||||
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
|
||||
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SearchSettings{}, nil
|
||||
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
||||
}
|
||||
return searchSettingsFrom(row), nil
|
||||
return s.settingsFrom(row), nil
|
||||
}
|
||||
|
||||
func (s *SearchSettingsService) defaultBase() *string {
|
||||
if s.defaultBaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
u := s.defaultBaseURL
|
||||
return &u
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The first update
|
||||
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
|
||||
if err != nil {
|
||||
return SearchSettings{}, err
|
||||
}
|
||||
return searchSettingsFrom(saved), nil
|
||||
return s.settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// apply returns row with patch applied, validated.
|
||||
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
|
||||
return len(cands), nil
|
||||
}
|
||||
|
||||
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
provider, updated := row.Provider, row.UpdatedAt
|
||||
return SearchSettings{
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
DefaultBaseURL: s.defaultBase(),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
const testBraveKey = "BSA-secret-brave-key-9f3c"
|
||||
|
||||
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
t.Helper()
|
||||
return newTestSearchSettingsWithDefault(t, "")
|
||||
}
|
||||
|
||||
// newTestSearchSettingsWithDefault is newTestSearchSettings with
|
||||
// defaultBaseURL as the deployment's SearXNG URL.
|
||||
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewSearchSettingsService(st, box)
|
||||
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
|
||||
got, err := svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
|
||||
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
|
||||
}
|
||||
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
|
||||
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
|
||||
}
|
||||
|
||||
// The default is a suggestion: SearXNG still needs a saved URL.
|
||||
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
|
||||
t.Error("choosing SearXNG without a URL succeeded with a default set")
|
||||
}
|
||||
|
||||
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
|
||||
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
|
||||
}
|
||||
got, err = svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" {
|
||||
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
|
||||
}
|
||||
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.BaseUrl.String != "https://search.example" {
|
||||
t.Errorf("stored base_url = %q", row.BaseUrl.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
|
||||
if _, err := NewSearchSettingsService(st, box, u); err == nil {
|
||||
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
|
||||
} else if strings.Contains(err.Error(), "pw") {
|
||||
t.Errorf("error for %q echoes the URL: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsTestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, recipeSearchPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
|
||||
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
|
||||
|
||||
@@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
||||
// configured, over a migrated temp database and a random secret box.
|
||||
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
t.Helper()
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user