safeSourceURL accepted hosts that Go's url.Parse takes but the browser's URL parser refuses, such as "https://[bad]/curry", so the detail API could send a source.url that sourceUrl() in web/src/dinners/detail.ts rejects, failing the whole recipe screen. The server now also requires a host the browser parses as it stands: a bracketed host must be an IPv6 address without a zone; any other must be ASCII letters, digits and hyphens in non-empty dot-separated labels, with no punycode label and, when its last label is a number, a valid dotted quad; and the host must be spelled out, not percent-encoded. Any other link is withheld as before. web/src/dinners/testdata/source_url_cases.json pins the source object the handler sends for each stored link. The Go server test checks the handler against it, and the recipe screen test renders each source from it, so the two parsers cannot drift apart on those links. Task: c1a97243-3e41-42fc-a1a0-1ac684ccb463 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
5.9 KiB
Go
178 lines
5.9 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")},
|
|
} {
|
|
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
|
if err != nil {
|
|
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
|
|
}
|
|
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
|
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
|
}
|
|
}
|
|
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
|
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", 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
|
|
}
|