Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #86.
This commit is contained in:
@@ -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 and model lists 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, maxAISettingsBody))
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"unicode/utf8"
|
||||
|
||||
"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. Its model list holds a model
|
||||
// whose ID and name quote 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"},`+
|
||||
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
|
||||
}
|
||||
}))
|
||||
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) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
|
||||
`{"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)
|
||||
}
|
||||
}
|
||||
|
||||
// The longest prompt fits the body limit however its 8,000 characters
|
||||
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
|
||||
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
|
||||
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
|
||||
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
|
||||
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
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) != 3 {
|
||||
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)
|
||||
}
|
||||
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
|
||||
t.Errorf("GET models[2] = %v, want the key redacted", 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)
|
||||
}
|
||||
|
||||
// The stored key is never pointed at another server or provider.
|
||||
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
|
||||
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
|
||||
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
|
||||
}
|
||||
}
|
||||
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
|
||||
t.Errorf("PUT empty key = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
|
||||
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
|
||||
t.Errorf("PATCH base_url without a 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,14 +9,21 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household or
|
||||
// /settings/recipe-search request body.
|
||||
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
|
||||
// longest valid system prompt at 12 bytes of JSON per character (a
|
||||
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
|
||||
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
|
||||
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
@@ -124,13 +131,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user