GET /jobs/{id} served a succeeded idea job's stored result as it was, so
a result stored before sources were made safe still showed a
token-bearing source_url and a site_name taken from the link's host
(BASE-01-R1). jobs.Kind.FailedResult becomes ShownResult, applied to
every stored result with the job's status: ideas_from_ingredients and
quick_ideas results show no ideas when failed and, otherwise, each
idea's source through dinners.SourceOf, the one rule, so no job old or
new serves an unsafe source. Other kinds' results are untouched.
dinners.SourceOf now parses the link before reading the site name, so a
link that does not parse is an error with or without a stored name, and
an idea job grounded in one fails instead of succeeding (BASE-01-R2).
Task: c15ed4b5-25ae-43c6-8d36-f4a6dd51e5af
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.3 KiB
Go
198 lines
8.3 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
)
|
|
|
|
// sourceCase is a recipe page an idea grounds in: its link and the site
|
|
// name its page gives (empty for none, so the host is stored instead).
|
|
type sourceCase struct {
|
|
dish, link, site string
|
|
// want is the idea's source_url and site_name; nil is null.
|
|
wantURL, wantSite any
|
|
}
|
|
|
|
const (
|
|
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
|
|
queryToken = "query-token-abc123"
|
|
queryHost = "query-host-q1.example"
|
|
userinfoToken = "user-token-def456"
|
|
userinfoHost = "userinfo-host-u2.example"
|
|
)
|
|
|
|
// sourceCases are a clean link, credited by its page's name, and links
|
|
// carrying a token in the query and the userinfo whose pages name no site,
|
|
// so the stored name is the link's host.
|
|
var sourceCases = []sourceCase{
|
|
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
|
|
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
|
|
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
|
|
}
|
|
|
|
// sourceSecrets are what no idea job response may contain.
|
|
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
|
|
|
|
// sourceFakes makes the search for each case's dish find its page and
|
|
// scripts the normalize_recipe reply grounding it takes.
|
|
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
|
t.Helper()
|
|
model, normalize, search, pages, images := newQuickFakes()
|
|
for i, c := range sourceCases {
|
|
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
|
|
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
|
|
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
|
|
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
|
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
|
}
|
|
return model, normalize, search, pages, images
|
|
}
|
|
|
|
// checkIdeaSources checks the finished job id: each idea shows its case's
|
|
// safe source, and the response holds nothing of a token-bearing link.
|
|
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
|
|
t.Helper()
|
|
job := waitJob(t, h, id)
|
|
if job["status"] != "succeeded" {
|
|
t.Fatalf("GET job = %v", job)
|
|
}
|
|
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
for _, secret := range sourceSecrets {
|
|
if strings.Contains(rec.Body.String(), secret) {
|
|
t.Errorf("job response contains %q: %s", secret, rec.Body)
|
|
}
|
|
}
|
|
list := job["result"].(map[string]any)["ideas"].([]any)
|
|
if len(list) != len(sourceCases) {
|
|
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
|
|
}
|
|
for i, c := range sourceCases {
|
|
idea := list[i].(map[string]any)
|
|
if idea["name"] != c.dish {
|
|
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
|
|
}
|
|
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
|
|
got, ok := idea[key]
|
|
if !ok || !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
|
|
}
|
|
}
|
|
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
|
|
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An ideas_from_ingredients job shows a clean link and its page's name,
|
|
// and neither a token-bearing link nor a name taken from its host.
|
|
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize, search, pages, images := sourceFakes(t, 25)
|
|
list := make([]string, len(sourceCases))
|
|
for i, c := range sourceCases {
|
|
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
|
|
}
|
|
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
checkIdeaSources(t, h, startIdeasJob(t, h))
|
|
}
|
|
|
|
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
|
|
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize, search, pages, images := sourceFakes(t, 25)
|
|
ideas := make([][3]any, len(sourceCases))
|
|
for i, c := range sourceCases {
|
|
ideas[i] = [3]any{c.dish, "stovetop", 20}
|
|
}
|
|
quickReply(model, ideas...)
|
|
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
checkIdeaSources(t, h, body["jobId"].(string))
|
|
}
|
|
|
|
// legacyIdea is an idea as the result builders stored it before sources
|
|
// were made safe: the grounded recipe's raw link and stored site name,
|
|
// which is the link's host when its page named no site.
|
|
func legacyIdea(name, link, site string) map[string]any {
|
|
return map[string]any{
|
|
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
|
|
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
|
|
"source_url": link, "site_name": site,
|
|
}
|
|
}
|
|
|
|
// A job of either idea kind whose result was stored before sources were
|
|
// made safe is served with the sources a job stored now holds: neither a
|
|
// token-bearing link nor a name taken from its host.
|
|
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize, search, pages, images := newQuickFakes()
|
|
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
|
legacy := []any{
|
|
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
|
|
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
|
|
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
|
|
}
|
|
for kind, result := range map[string]map[string]any{
|
|
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
|
|
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
|
|
} {
|
|
js, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := "legacy-" + kind
|
|
if _, err := st.Writer.ExecContext(t.Context(),
|
|
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
|
|
id, kind, string(js)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkIdeaSources(t, h, id)
|
|
}
|
|
}
|
|
|
|
// An idea job whose grounded recipe's stored link does not parse fails,
|
|
// even when no site name is stored, rather than show the idea without a
|
|
// source.
|
|
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
|
|
t.Parallel()
|
|
const badLink = "https://kitchen.example/%zz"
|
|
model, normalize, search, pages, images := sourceFakes(t, 25)
|
|
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
|
|
js := `{"ideas":[` + idea + `]}`
|
|
for range 2 {
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
}
|
|
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
|
|
t.Fatalf("grounding job = %v", job)
|
|
}
|
|
// The recipe is now stored under badLink with no site name, and the
|
|
// search finds it there.
|
|
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
|
t.Fatalf("updated %d recipes (%v), want 1", n, err)
|
|
}
|
|
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
|
|
job := waitJob(t, h, startIdeasJob(t, h))
|
|
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
|
|
}
|