Add AI settings API with key management

GET/PATCH /api/v1/settings/ai return and change provider, base_url,
model, temperature and system_prompt, with read-only api_key_last4 and
key_verified_at. PATCH requires a preset provider, temperature 0-1 in
0.1 steps and a system_prompt of at most 8000 characters.
PUT /settings/ai/api-key seals the key with secretbox and records its
last four characters; POST /settings/ai/verify calls Provider.Verify and
records key_verified_at or returns the provider's typed error;
GET /settings/ai/models lists the live models; POST
/settings/ai/reset-prompt restores household.DefaultPersonaPrompt.

The new internal/ai/aisettings service also builds the harness.Config
for each skill run from the saved settings. presets.ForAccount builds a
provider's ListModels and Verify without a model, so a key can be
checked and a model picked before one is chosen.

Tests scan every response body from these routes for the full key, and
a harness run with the Fake provider uses the PATCHed model and
temperature.

Maestro task: 176b2a30-6e90-4339-b430-5b8967b2fd32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:50:12 -04:00
co-authored by Claude Opus 5.5
parent f25b28b39b
commit 60a92ec78f
17 changed files with 1416 additions and 31 deletions
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
// the stored key.
type apiKeyBody struct {
APIKey *string `json:"api_key"`
}
// modelsResponse is the GET /settings/ai/models body.
type modelsResponse struct {
Models []aisettings.Model `json:"models"`
}
// No response from these handlers holds the API key: the settings carry
// only its last four characters, and provider errors are redacted by
// package aisettings.
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
}
}
// putAIKey takes the API key in api_key; the response, like GET's, holds
// only its last four characters.
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err == nil && body.APIKey == nil {
err = errors.New("api_key: is required")
}
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
}
}
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
}
}
// postAIVerify checks the stored key with the provider and returns the
// settings with key_verified_at, or the failure (see writeAIFailure).
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Verify(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "verify AI key", err)
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
// getAIModels returns the provider's live model list, or the failure (see
// writeAIFailure).
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
models, err := svc.Models(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "list AI models", err)
return
}
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
}
}
// writeAISettings returns a func writing a Service result: the settings,
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
return func(settings aisettings.Settings, err error) {
var invalid *aisettings.ValidationError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
case err != nil:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
default:
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
}
// writeAIFailure writes a Verify or Models error: 409 not_configured when
// the settings cannot build a provider, 409 settings_changed when they
// changed during verification, and for a failed provider call a 502 (504
// for a timeout) whose code names the llm typed error: auth_failed,
// rate_limited, timeout, bad_output or provider_error. Rate limits and
// timeouts are retryable. Anything else is 500.
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, aisettings.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
case errors.Is(err, aisettings.ErrChanged):
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
case errors.Is(err, aisettings.ErrProviderFailed):
logger.WarnContext(r.Context(), action+" failed", "error", err)
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
switch {
case errors.Is(err, llm.ErrAuth):
e.Code = "auth_failed"
case errors.Is(err, llm.ErrRateLimited):
e.Code, e.Retryable = "rate_limited", true
case errors.Is(err, llm.ErrTimeout):
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
case errors.Is(err, llm.ErrBadOutput):
e.Code = "bad_output"
}
writeAPIError(w, r, logger, status, e)
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+240
View File
@@ -0,0 +1,240 @@
package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const (
aiPath = "/api/v1/settings/ai"
aiKeyPath = "/api/v1/settings/ai/api-key"
aiVerifyPath = "/api/v1/settings/ai/verify"
aiModelsPath = "/api/v1/settings/ai/models"
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
testAIKey = "sk-test-secret-ai-key-7d21"
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
)
// newAIRouter returns a router with a fresh, seeded AI settings service.
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
// only testAIKey. While echo is set it answers 500 with a body quoting the
// Authorization header it received, so a response that passed the vendor
// body through unredacted would leak the key.
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
switch {
case r.URL.Path != "/v1/models":
http.NotFound(w, r)
case echo.Load():
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`)
case auth != "Bearer "+testAIKey:
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
default:
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"}]}`)
}
}))
t.Cleanup(srv.Close)
return srv
}
func errorCode(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
// TestAISettingsAPI drives every /settings/ai route and scans every
// response body for the API keys.
func TestAISettingsAPI(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
var echo atomic.Bool
vendor := newModelsServer(t, &echo)
var bodies []string
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
rec, decoded := serve(t, router, signedIn(method, target, r))
bodies = append(bodies, rec.Body.String())
return rec, decoded
}
// The seed: anthropic, the default persona, no key.
rec, body := call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt ||
body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("GET seed = %d %v", rec.Code, body)
}
for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} {
if _, ok := body[field]; !ok {
t.Errorf("GET has no %s field: %v", field, body)
}
}
for _, path := range []string{aiVerifyPath, aiModelsPath} {
method := http.MethodPost
if path == aiModelsPath {
method = http.MethodGet
}
if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" {
t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body)
}
}
for _, bad := range []string{
`{}`,
`{"provider":"google"}`,
`{"provider":"openai_compatible","base_url":"not a url"}`,
`{"base_url":"https://evil.example"}`,
`{"temperature":1.1}`,
`{"temperature":-0.1}`,
`{"temperature":0.25}`,
`{"temperature":"0.5"}`,
`{"model":""}`,
`{"model":null}`,
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
`{"api_key":"` + testAIKey + `"}`,
} {
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body)
}
}
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body)
}
for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} {
if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body)
}
}
// A rejected key is a typed auth failure and records nothing.
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil {
t.Errorf("PUT wrong key = %d %v", rec.Code, body)
}
if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" {
t.Errorf("PUT key = %d %v", rec.Code, body)
}
if _, ok := body["api_key"]; ok {
t.Errorf("PUT response has an api_key field: %v", body)
}
// The vendor quoting the key in an error body: the body excerpt
// reaches the response redacted.
echo.Store(true)
for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} {
rec, body := call(c.method, c.path, "")
if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") {
t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body)
}
}
echo.Store(false)
rec, body = call(http.MethodPost, aiVerifyPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" {
t.Errorf("POST verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("GET after verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiModelsPath, "")
models, _ := body["models"].([]any)
if rec.Code != http.StatusOK || len(models) != 2 {
t.Fatalf("GET models = %d %v", rec.Code, body)
}
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
t.Errorf("GET models[1] = %v", m)
}
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {
t.Errorf("PATCH system_prompt = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, aiResetPromptPath, "")
if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt {
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
}
// Pointing the key at another server voids its verification.
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
if rec.Code != http.StatusOK || body["key_verified_at"] != nil || body["api_key_last4"] != "7d21" {
t.Errorf("PATCH base_url = %d %v", rec.Code, body)
}
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil {
t.Errorf("PUT empty key = %d %v", rec.Code, body)
}
if len(bodies) < 30 {
t.Fatalf("scanned only %d response bodies", len(bodies))
}
for _, b := range bodies {
for _, key := range []string{testAIKey, testWrongAIKey} {
if strings.Contains(b, key) {
t.Errorf("response holds an API key: %s", b)
}
}
}
}
func TestAISettingsWritesNeedCSRF(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
for _, c := range []struct{ method, path, body string }{
{http.MethodPatch, aiPath, `{"temperature":0.5}`},
{http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`},
{http.MethodPost, aiVerifyPath, ""},
{http.MethodPost, aiResetPromptPath, ""},
} {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code)
}
}
rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil))
if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 {
t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+14 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -31,18 +32,23 @@ type RouteMounter interface {
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; runner serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if search == nil {
return nil, errors.New("server: nil recipe search settings service")
}
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -80,6 +86,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
apiV1.Get("/settings/ai", getAISettings(logger, ai))
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+43 -12
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -42,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -131,24 +132,54 @@ 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()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
search, err := recipes.NewSearchSettingsService(newTestStore(t), box)
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
if err != nil {
t.Fatal(err)
}
return search
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
return box
}
// newTestAI returns an AI settings service over a migrated temp database
// with the seed AI settings and a random secret box.
func newTestAI(t *testing.T) *aisettings.Service {
t.Helper()
st := newTestStore(t)
if err := household.SeedAISettings(context.Background(), st); err != nil {
t.Fatal(err)
}
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return ai
}
func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -157,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -166,7 +197,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -175,7 +206,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+9 -4
View File
@@ -13,8 +13,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// maxSettingsBody bounds a PATCH /settings/household or
// /settings/recipe-search request body.
// maxSettingsBody bounds a PATCH /settings/household, /settings/ai or
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
const maxSettingsBody = 16 << 10
// settingsPatch is the PATCH /settings/household body. Every field is
@@ -124,13 +124,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
// writeError responds with status and a non-retryable API error.
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
}
// writeAPIError responds with status and e.
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
body, err := json.Marshal(errorBody{Error: e})
if err != nil {
panic(err) // string and bool fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}