Hide candidate URLs, report unavailable notifications, clear grounded links
CI / ci (push) Successful in 2m41s
CI / ci (push) Successful in 2m41s
Release corrections from the review of promotion pull request #98: - MQ-98-01: recipe-candidates returns an opaque candidate_id (an HMAC of the dinner and page under a per-process key) that the server resolves to the page it last offered that dinner. Candidate URLs, which may carry a token, no longer reach responses or the service-worker cache. - MQ-98-02: "Turn on notifications" no longer finishes onboarding silently when notifications are unavailable (or denied); the step says plainly that nothing was turned on and offers "Finish setting up". The placeholder stays until Feature 19 wires Web Push. - MQ-98-03: GroundPendingDinner clears the pasted recipe_url when background grounding succeeds, as manual grounding already did. Maestro task 6819ce33-3715-4bd4-9208-a54a8b5cee02. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -140,6 +141,15 @@ type Service struct {
|
||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||
groundingsMu sync.Mutex
|
||||
groundings map[string]map[*runningGrounding]struct{}
|
||||
|
||||
// offerKey keys the candidate ids Candidates offers, so they are
|
||||
// opaque and stable for this process.
|
||||
offerKey []byte
|
||||
// offersMu guards offers: by dinner id, the page URL of each candidate
|
||||
// id last offered by Candidates, so a candidate id is resolved here
|
||||
// and its URL never leaves the server.
|
||||
offersMu sync.Mutex
|
||||
offers map[string]map[string]string
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
@@ -156,7 +166,8 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]map[string]string)}
|
||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,7 +2,10 @@ package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -282,7 +285,9 @@ func reasonFor(err error, fromURL bool) string {
|
||||
|
||||
// Candidate is a recipe page offered to ground a dinner in.
|
||||
type Candidate struct {
|
||||
// CandidateID is the page's URL; Fix.CandidateID grounds it.
|
||||
// CandidateID is an opaque id for the page, resolved by the server:
|
||||
// Fix.CandidateID grounds it. The page's URL is never returned, as it
|
||||
// may carry a token.
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Title string `json:"title"`
|
||||
SiteName string `json:"site_name"`
|
||||
@@ -292,8 +297,9 @@ type Candidate struct {
|
||||
}
|
||||
|
||||
// Candidates searches for dinner id's name and returns up to MaxCandidates
|
||||
// recipe pages to ground it in, best first; the list may be empty. A
|
||||
// search that fails wraps recipes.ErrSearchFailed.
|
||||
// recipe pages to ground it in, best first; the list may be empty. They
|
||||
// replace the dinner's earlier candidates: one not offered again no longer
|
||||
// grounds it. A search that fails wraps recipes.ErrSearchFailed.
|
||||
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
||||
row, err := s.get(ctx, id)
|
||||
if err != nil {
|
||||
@@ -304,16 +310,47 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Candidate, len(offers))
|
||||
urls := make(map[string]string, len(offers))
|
||||
for i, o := range offers {
|
||||
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
cid := s.candidateID(row.ID, o.URL)
|
||||
urls[cid] = o.URL
|
||||
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
if o.PhotoID != "" {
|
||||
u := PhotoURL(o.PhotoID)
|
||||
out[i].PhotoURL = &u
|
||||
}
|
||||
}
|
||||
s.offersMu.Lock()
|
||||
s.offers[row.ID] = urls
|
||||
s.offersMu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// candidateID is the opaque id of the page at rawURL as a candidate for
|
||||
// dinner id: the same page is offered under the same id until the service
|
||||
// restarts, and the id reveals nothing of the URL.
|
||||
func (s *Service) candidateID(id, rawURL string) string {
|
||||
mac := hmac.New(sha256.New, s.offerKey)
|
||||
mac.Write([]byte(id + "\x00" + rawURL))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
|
||||
}
|
||||
|
||||
// candidateURL is the page URL of candidate cid last offered for dinner id.
|
||||
// A cid not among the dinner's candidates last offered since the service
|
||||
// started, or whose page is not a valid recipe link, is a
|
||||
// *ValidationError.
|
||||
func (s *Service) candidateURL(id, cid string) (string, error) {
|
||||
s.offersMu.Lock()
|
||||
raw, ok := s.offers[id][cid]
|
||||
s.offersMu.Unlock()
|
||||
if ok {
|
||||
if u, err := checkRecipeURL(raw); err == nil {
|
||||
return u.String(), nil
|
||||
}
|
||||
}
|
||||
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
|
||||
}
|
||||
|
||||
// Fix grounds a dinner by hand: exactly one of a candidate from
|
||||
// Candidates, a pasted recipe link or a recipe typed in.
|
||||
type Fix struct {
|
||||
@@ -367,6 +404,11 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
if cur.Status == StatusArchived {
|
||||
return Dinner{}, ErrArchived
|
||||
}
|
||||
if f.CandidateID != nil {
|
||||
if link, err = s.candidateURL(cur.ID, *f.CandidateID); err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
}
|
||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||
defer cancel()
|
||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||
@@ -401,6 +443,9 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.offersMu.Lock()
|
||||
delete(s.offers, cur.ID)
|
||||
s.offersMu.Unlock()
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
@@ -550,11 +595,8 @@ func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
|
||||
}
|
||||
switch {
|
||||
case f.CandidateID != nil:
|
||||
u, err := checkRecipeURL(*f.CandidateID)
|
||||
if err != nil {
|
||||
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
|
||||
}
|
||||
return u.String(), nil, nil
|
||||
// Ground resolves the candidate to its page.
|
||||
return "", nil, nil
|
||||
case f.RecipeURL != nil:
|
||||
u, err := checkRecipeURL(*f.RecipeURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -214,6 +215,39 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateByRecipeURLClearsLink: once background grounding resolves a
|
||||
// pasted link, which may carry a personal token, the dinner no longer
|
||||
// stores it; the recipe it grounded in records the page.
|
||||
func TestCreateByRecipeURLClearsLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
const link = "https://c.example/recipes/beef-stew?access_token=personal-token-abc123"
|
||||
e.pages.Bodies[link] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(link)}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
row := e.dinner(t, c.ID)
|
||||
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid {
|
||||
t.Fatalf("dinner = %+v, want grounded", row)
|
||||
}
|
||||
if row.RecipeUrl.Valid {
|
||||
t.Errorf("recipe_url = %q after grounding, want cleared", row.RecipeUrl.String)
|
||||
}
|
||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.SourceUrl != link {
|
||||
t.Errorf("recipe source = %q, want %q", rec.SourceUrl, link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
@@ -642,6 +676,26 @@ func failedDinner(t *testing.T, e *env, name string) string {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
// candidateID returns the candidate_id Candidates offers dinner id for the
|
||||
// page titled title.
|
||||
func candidateID(t *testing.T, e *env, id, title string) string {
|
||||
t.Helper()
|
||||
got, err := e.svc.Candidates(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Title == title {
|
||||
if strings.Contains(c.CandidateID, "://") {
|
||||
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", c.CandidateID)
|
||||
}
|
||||
return c.CandidateID
|
||||
}
|
||||
}
|
||||
t.Fatalf("candidates = %+v, want one titled %q", got, title)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
@@ -664,14 +718,25 @@ func TestCandidates(t *testing.T) {
|
||||
}
|
||||
first, second := got[0], got[1]
|
||||
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
|
||||
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
|
||||
if first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
|
||||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
|
||||
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
|
||||
}
|
||||
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
|
||||
if second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
|
||||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
|
||||
t.Errorf("second = %+v, want curryB without a photo", second)
|
||||
}
|
||||
if first.CandidateID == "" || first.CandidateID == second.CandidateID ||
|
||||
first.CandidateID == curryA || second.CandidateID == curryB {
|
||||
t.Errorf("candidate ids = %q, %q, want distinct opaque ids", first.CandidateID, second.CandidateID)
|
||||
}
|
||||
again, err := e.svc.Candidates(t.Context(), c.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 2 || again[0].CandidateID != first.CandidateID || again[1].CandidateID != second.CandidateID {
|
||||
t.Errorf("candidates again = %+v, want the same ids", again)
|
||||
}
|
||||
|
||||
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
|
||||
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
|
||||
@@ -702,9 +767,11 @@ func TestFixViaCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
id := failedDinner(t, e, "Mystery curry")
|
||||
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
|
||||
cid := candidateID(t, e, id, "Easy Weeknight Curry")
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
|
||||
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
|
||||
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, "member-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -729,12 +796,18 @@ func TestFixReplacesPendingLink(t *testing.T) {
|
||||
const lost = "https://gone.example/recipes/lamb-tagine"
|
||||
tests := []struct {
|
||||
name string
|
||||
fix dinners.Fix
|
||||
// fix is the fix for dinner id.
|
||||
fix func(t *testing.T, e *env, id string) dinners.Fix
|
||||
// linkTaken is the recipe link the fixed dinner now has, if any.
|
||||
linkTaken string
|
||||
}{
|
||||
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
|
||||
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
|
||||
{"candidate", func(t *testing.T, e *env, id string) dinners.Fix {
|
||||
e.search.Results["Weeknight dinner"] = []recipes.Candidate{{URL: curryB}}
|
||||
return dinners.Fix{CandidateID: ptr(candidateID(t, e, id, "Easy Weeknight Curry"))}
|
||||
}, curryB},
|
||||
{"manual", func(*testing.T, *env, string) dinners.Fix {
|
||||
return dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}
|
||||
}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -752,7 +825,7 @@ func TestFixReplacesPendingLink(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
|
||||
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix(t, e, c.ID), member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
|
||||
@@ -855,7 +928,8 @@ func TestFixValidation(t *testing.T) {
|
||||
tests := map[string]dinners.Fix{
|
||||
"nothing": {},
|
||||
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
|
||||
"candidate not URL": {CandidateID: ptr("cand-1")},
|
||||
"unknown candidate": {CandidateID: ptr("cand-1")},
|
||||
"candidate a URL": {CandidateID: ptr(curryA)},
|
||||
"http link": {RecipeURL: ptr("http://a.example/x")},
|
||||
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
|
||||
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
|
||||
|
||||
Reference in New Issue
Block a user