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>
181 lines
6.0 KiB
Go
181 lines
6.0 KiB
Go
package dinners_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
)
|
|
|
|
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
|
|
// is named only by the household, so no part of the link — host, path,
|
|
// query or fragment — is stored as its name or shown in a dinner or a
|
|
// duplicate error, even a token of plain letters in the path.
|
|
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#lookup"
|
|
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
|
|
// O, U, "-", "." or "/"), so a dinner's id never matches one.
|
|
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
|
|
check := func(what, text string) {
|
|
t.Helper()
|
|
lower := strings.ToLower(text)
|
|
for _, leak := range leaks {
|
|
if strings.Contains(lower, leak) {
|
|
t.Errorf("%s contains %q: %s", what, leak, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
|
|
var verr *dinners.ValidationError
|
|
if !errors.As(err, &verr) {
|
|
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
|
|
}
|
|
check("validation error", verr.Error())
|
|
|
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.Name != "Sunday stew" {
|
|
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
|
|
}
|
|
created, err := json.Marshal(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("create response", string(created))
|
|
row := e.dinner(t, c.ID)
|
|
check("stored name", row.Name+" "+row.NormalizedName)
|
|
if row.RecipeUrl.String != link {
|
|
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
|
|
}
|
|
|
|
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed, err := json.Marshal(list)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("list response", string(listed))
|
|
|
|
// The same link under another name is still a duplicate, and its error
|
|
// names the existing dinner by the household's name.
|
|
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
|
|
var dup *dinners.DuplicateError
|
|
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
|
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
|
|
}
|
|
check("duplicate error", dup.Error())
|
|
}
|
|
|
|
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
|
|
// the link's own, in any case and without "www.", an IP address or a dotted
|
|
// host name — is withheld; a name the page gave, dotless or with spaces, is
|
|
// kept.
|
|
func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
|
t.Parallel()
|
|
const link = "https://www.TokenHost/recipes/stew"
|
|
for _, tc := range []struct {
|
|
stored string
|
|
valid bool
|
|
want *string
|
|
}{
|
|
{"", false, nil},
|
|
{"tokenhost", true, nil},
|
|
{"TOKENHOST", true, nil},
|
|
{"private-token.example.com", true, nil},
|
|
{"Food.com", true, nil},
|
|
{"203.0.113.7", true, nil},
|
|
{"2001:db8::1", true, nil},
|
|
{"Delish", true, ptr("Delish")},
|
|
{"Bon Appétit", true, ptr("Bon Appétit")},
|
|
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
|
} {
|
|
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
|
if err != nil {
|
|
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
|
|
}
|
|
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
|
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
|
}
|
|
}
|
|
// A link that does not parse is an error with or without a site name.
|
|
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
|
|
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
|
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
|
|
// is https to a host with no user, password, query or fragment, each of
|
|
// which can carry a token; any other link, or one that does not parse, is
|
|
// withheld whole.
|
|
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
for _, link := range []string{
|
|
"https://www.example.com/recipes/chicken-curry",
|
|
"https://example.com",
|
|
"https://example.com:8443/stew/",
|
|
"https://[2001:db8::1]/curry",
|
|
"https://1.2.3.4/curry",
|
|
"https://Kitchen-2.Example.com./curry",
|
|
} {
|
|
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
|
|
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
|
|
}
|
|
}
|
|
for _, link := range []string{
|
|
"https://example.com/curry?access_token=query-token",
|
|
"https://example.com/curry?",
|
|
"https://user:password@example.com/curry",
|
|
"https://token@example.com/curry",
|
|
"https://@example.com/curry",
|
|
"http://example.com/curry",
|
|
"https://example.com/curry#token=fragment-token",
|
|
"https://example.com/curry#",
|
|
"ftp://example.com/curry",
|
|
"https:example.com/curry",
|
|
"https:///curry",
|
|
"https://example.com:99999/curry",
|
|
"https://example.com:0/curry",
|
|
"//example.com/curry",
|
|
"https://secret-token\x7f.example/%zz",
|
|
"",
|
|
// Hosts url.Parse accepts but the browser's URL parser refuses
|
|
// (or reads as another host), which would fail the whole recipe
|
|
// screen.
|
|
"https://[bad]/curry",
|
|
"https://[fe80::1%25en0]/curry",
|
|
"https://[1.2.3.4]/curry",
|
|
"https://1.2.3.999/curry",
|
|
"https://1.2.3.4.5/curry",
|
|
"https://999/curry",
|
|
"https://example.0x/curry",
|
|
"https://xn--a/curry",
|
|
"https://ex%61mple.com/curry",
|
|
"https://ex%2fample.com/curry",
|
|
"https://a..b/curry",
|
|
"https://café.example/curry",
|
|
} {
|
|
if got := dinners.SafeSourceURL(link); got != nil {
|
|
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func deref(s *string) any {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return *s
|
|
}
|