Withhold a site name that could be the recipe link's host
CI / ci (push) Successful in 5m51s
CI / ci (pull_request) Successful in 5m1s

A page that names no site is stored under its host, which may be the
pasted link's (a token in a subdomain) or a redirect's. The dinner
detail now returns source.site_name only when the stored name could not
be a host: the link's own host, an IP address or a dotted host name is
withheld as null, so "Recipe from {site}" shows only real attribution.
The extractor's host fallback is shared as recipes.HostSiteName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:15:42 -04:00
co-authored by Claude Opus 5.5
parent d37a784190
commit 58047059f9
7 changed files with 183 additions and 8 deletions
+74
View File
@@ -265,6 +265,80 @@ func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe"
named = "https://www." + namedToken + ".example.com/recipe"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
t.Fatal(err)
}
return id, recipeID
}
storedSite := func(recipeID string) string {
t.Helper()
var site string
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
}
check := func(what, id string, leaks []string, want any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, leak := range leaks {
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want}) {
t.Errorf("source of %s = %v, want only site_name %v", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com", `"url"`}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com", `"url"`}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
// the suggestion that created the dinner, never that of an earlier
// suggestion row for it that was declined.
+5 -1
View File
@@ -72,7 +72,11 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show.
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))