Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc83f3cd3a | ||
|
|
94a3de309b | ||
|
|
7b03d16ec0 |
@@ -58,16 +58,15 @@ type Ingredient struct {
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// Source is the site a recipe was fetched from. SiteName is the name the
|
||||
// page itself gives its site, and null when it gives none or the stored
|
||||
// name could have come from a link (see pageSiteName), so a client shows
|
||||
// "Recipe from {site}" only when SiteName is not null. URL is the page's
|
||||
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
||||
// null otherwise: a link pasted or offered as a candidate may carry a
|
||||
// personal token, so a client links the credit only when URL is not null.
|
||||
// Source is the site a recipe was fetched from. It has no url: the page's
|
||||
// link, pasted or offered as a candidate, may carry a personal token, and
|
||||
// no stored link can be shown to be free of one, so like a dinner's
|
||||
// recipe_url and a candidate's page it never leaves the server. SiteName
|
||||
// is the name the page itself gives its site, and null when it gives none
|
||||
// or the stored name could have come from a link (see pageSiteName), so a
|
||||
// client shows "Recipe from {site}" only when SiteName is not null.
|
||||
type Source struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
URL *string `json:"url"`
|
||||
}
|
||||
|
||||
// Get returns dinner id with its recipe and the reason it was suggested,
|
||||
@@ -150,7 +149,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
|
||||
d.Source = &Source{SiteName: site}
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipeID)
|
||||
if err != nil {
|
||||
@@ -210,69 +209,6 @@ func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
|
||||
// https link to a host, with no user or password, no query (not even a
|
||||
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
|
||||
// the places a link carries a token; a link failing any check, or not
|
||||
// parsing, is withheld rather than trimmed, since a trimmed link is not the
|
||||
// page the recipe came from. The client (sourceUrl in
|
||||
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
|
||||
// and refuses the whole recipe when it fails, so the host and port must
|
||||
// also be ones it parses: see browserHost.
|
||||
func safeSourceURL(sourceURL string) *string {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
|
||||
return nil
|
||||
}
|
||||
// url.Parse decodes a percent-encoded host; the link must spell its
|
||||
// host out as it is checked.
|
||||
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
|
||||
if authority != u.Host || !browserHost(u) {
|
||||
return nil
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &sourceURL
|
||||
}
|
||||
|
||||
// browserHost says whether u's host is one the browser's URL parser (the
|
||||
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
|
||||
// such as "[bad]". The rule is narrower than the browser's: a bracketed
|
||||
// host is an IPv6 address without a zone; any other is ASCII letters,
|
||||
// digits and hyphens in dot-separated labels, none empty but a trailing
|
||||
// one, none punycode ("xn--"), since the browser refuses invalid punycode
|
||||
// and checking it is not worth a decoder; and a host whose last label is
|
||||
// a number, which the browser reads as an IPv4 address, must be a dotted
|
||||
// quad. A link to any other host is withheld.
|
||||
func browserHost(u *url.URL) bool {
|
||||
host := u.Hostname()
|
||||
if strings.HasPrefix(u.Host, "[") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && strings.Contains(host, ":")
|
||||
}
|
||||
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
last := labels[len(labels)-1]
|
||||
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// duplicatePosition is the error for two recipe items of kind at position.
|
||||
// Position is the only record of recipe order, and the id that breaks the
|
||||
// tie says nothing about it, so a recipe with a duplicate is refused rather
|
||||
|
||||
@@ -112,63 +112,6 @@ func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -74,6 +74,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
|
||||
// PageSiteName is pageSiteName.
|
||||
var PageSiteName = pageSiteName
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -112,7 +111,7 @@ func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
||||
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
|
||||
},
|
||||
Steps: []string{"Fry with the garlic.", "Chop the onion."},
|
||||
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
|
||||
Source: &dinners.Source{SiteName: str("Curry House")},
|
||||
GroundingStatus: "grounded",
|
||||
WhyPicked: str(reason),
|
||||
}
|
||||
@@ -217,113 +216,51 @@ func TestGetDinnerNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
|
||||
// link shows it as source.url; one whose stored link carries a token in its
|
||||
// query or userinfo, is http, or has a fragment shows its site name but
|
||||
// source.url null and nothing of the link.
|
||||
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
|
||||
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
|
||||
// carries a token in its path or query shows its site name, but never the
|
||||
// link or any part of it.
|
||||
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
clean = "https://kitchen.example/recipes/chicken-curry"
|
||||
pathToken = "path-token-abc123"
|
||||
queryToken = "query-token-def456"
|
||||
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||
)
|
||||
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "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 string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
for _, tc := range []struct{ dish, page, site string }{
|
||||
{"Chicken curry", pathPage, "Curry House"},
|
||||
{"Weeknight curry", queryPage, "B Kitchen"},
|
||||
} {
|
||||
id := createDinner(t, e.h, `{"name":"`+tc.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 rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+tc.page+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground %s = %d: %s", tc.page, rec.Code, rec.Body)
|
||||
}
|
||||
var stored string
|
||||
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != page {
|
||||
t.Fatalf("stored source_url = %q, want %q", stored, page)
|
||||
if stored != tc.page {
|
||||
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
|
||||
}
|
||||
return id
|
||||
}
|
||||
source := func(what, id string) map[string]any {
|
||||
t.Helper()
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
src, ok := raw["source"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
cleanID := ground("Chicken curry", clean)
|
||||
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
|
||||
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
|
||||
}
|
||||
|
||||
queryID := ground("Weeknight curry", queryPage)
|
||||
withheld := func(what, id, site string, secrets ...string) {
|
||||
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)
|
||||
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
|
||||
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
|
||||
}
|
||||
}
|
||||
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
|
||||
}
|
||||
}
|
||||
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
|
||||
|
||||
// The other unsafe shapes, stored as a link grounded elsewhere would
|
||||
// leave them.
|
||||
for _, tc := range []struct{ what, link string }{
|
||||
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
|
||||
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
|
||||
}
|
||||
|
||||
// web/src/dinners/testdata/source_url_cases.json pins source as this
|
||||
// handler sends it for each stored link; the recipe screen's tests
|
||||
// render each one, so a link the server sends and the client refuses
|
||||
// fails one side or the other.
|
||||
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var shared struct {
|
||||
Cases []struct {
|
||||
Stored string
|
||||
Source map[string]any
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(fixture, &shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shared.Cases) == 0 {
|
||||
t.Fatal("source_url_cases.json has no cases")
|
||||
}
|
||||
for _, c := range shared.Cases {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
|
||||
got, _ := json.Marshal(src)
|
||||
want, _ := json.Marshal(c.Source)
|
||||
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
|
||||
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,16 +270,14 @@ func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
|
||||
// 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.
|
||||
// The links carry a query, so source.url is withheld too and nothing of the
|
||||
// link may appear.
|
||||
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?print=1"
|
||||
named = "https://www." + namedToken + ".example.com/recipe?print=1"
|
||||
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)
|
||||
@@ -380,8 +315,8 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,13 +324,13 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
|
||||
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"}, nil)
|
||||
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"}, "Curry House")
|
||||
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 {
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// A dinner's detail offline once visited: the page reads through the service
|
||||
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
|
||||
// offline is what the worker saved while it was online. jsdom has no service
|
||||
// worker, so the strategy runs directly against an in-memory cache.
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OfflinePill } from '../pwa/OfflinePill.tsx'
|
||||
import { AppShell } from '../shell/AppShell.tsx'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
|
||||
function grounded(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
}
|
||||
}
|
||||
|
||||
// server is what the server sends while it can be reached. Kitchen reading
|
||||
// mode also reads the household settings, which the worker never saves, and
|
||||
// the week's plans to find the dinner's day, of which there are none here.
|
||||
const server: Record<string, unknown> = {
|
||||
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
|
||||
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
|
||||
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
|
||||
'/api/v1/dinners/d3': {
|
||||
...grounded('d3', 'Pork carnitas'),
|
||||
photo_urls: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'pending',
|
||||
},
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
let reachable: boolean
|
||||
// networkReads is every path the network was asked for, reached or not.
|
||||
let networkReads: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
cache = stubWorkerCaches()
|
||||
reachable = true
|
||||
networkReads = []
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
networkReads.push(path)
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
|
||||
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
return jsonResponse(200, server[path])
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// open loads /dinner/{id} as a fresh page load would: a new API client,
|
||||
// network status and query cache, with the offline indicator App shows.
|
||||
function open(id: string) {
|
||||
const router = createMemoryRouter(
|
||||
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
|
||||
{ initialEntries: [`/dinner/${id}`] },
|
||||
)
|
||||
return render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
<OfflinePill />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
// visit opens a dinner while online and leaves once its title shows.
|
||||
async function visit(id: string, name: string) {
|
||||
const page = open(id)
|
||||
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
|
||||
page.unmount()
|
||||
}
|
||||
|
||||
describe('dinner detail offline', () => {
|
||||
it('saves a visited dinner in the API cache the worker reads offline', async () => {
|
||||
mockViewport(390)
|
||||
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'Ground beef1 lb',
|
||||
'Jasmine rice2 cups',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
|
||||
mockViewport(1180)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
|
||||
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
|
||||
})
|
||||
|
||||
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d2')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d3', 'Pork carnitas')
|
||||
|
||||
reachable = false
|
||||
open('d3')
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
// Read once online and tried once offline before the saved copy answered.
|
||||
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
|
||||
'/api/v1/dinners/d3',
|
||||
'/api/v1/dinners/d3',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { GROUNDING_POLL_MS } from './api.ts'
|
||||
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
|
||||
import sourceCases from './testdata/source_url_cases.json'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
|
||||
@@ -187,23 +186,6 @@ describe('DinnerDetailPage', () => {
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Each source exactly as the Go handler sends it for a stored link
|
||||
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
|
||||
// file), so every url the server sends must parse here.
|
||||
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
|
||||
serve(detail({ source }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Curry House')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
if (source.url === null) {
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the credit out when the server names no site', async () => {
|
||||
serve(detail({ source: { site_name: null } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[2001:db8::1]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.7/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[bad]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://[fe80::1%25en0]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.999/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://xn--a.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://user-token@kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "http://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +1,51 @@
|
||||
// Runs the real Workbox network-first strategy against an in-memory cache
|
||||
// storage: jsdom has none, and the worker itself needs service worker globals.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { cacheFallbackHeader } from './routes'
|
||||
import { createApiStrategy } from './apiStrategy'
|
||||
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = stubWorkerCaches()
|
||||
cache = new FakeCache()
|
||||
// Workbox checks its arguments against these worker-only globals.
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -23,6 +58,17 @@ async function handle(path: string): Promise<Response> {
|
||||
return answer(new Request(new URL(path, origin)))
|
||||
}
|
||||
|
||||
async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
function recipe(): Response {
|
||||
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
|
||||
status: 200,
|
||||
@@ -61,13 +107,22 @@ describe('previously viewed plan and recipe reads', () => {
|
||||
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
|
||||
let reachable: boolean
|
||||
|
||||
// The page's fetch goes through the worker's API strategy; the strategy's
|
||||
// own fetch (always a Request) is the network. The API client fetches a
|
||||
// path string, so the two are told apart by the argument's type. As in a
|
||||
// browser, a worker that cannot answer is a network error to the page.
|
||||
beforeEach(() => {
|
||||
reachable = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!(input instanceof Request)) {
|
||||
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
}
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
const body = server[new URL(request.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
const body = server[new URL(input.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
|
||||
return jsonResponse(200, body)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Runs requests through the service worker's real API strategy against an
|
||||
// in-memory cache storage: jsdom has neither a service worker nor caches.
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiStrategy } from '../sw/apiStrategy.ts'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
|
||||
export const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
export class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
// stubWorkerCaches stubs the worker globals Workbox checks and a cache
|
||||
// storage holding only the API cache, which it returns. Undo with
|
||||
// vi.unstubAllGlobals.
|
||||
export function stubWorkerCaches(): FakeCache {
|
||||
const cache = new FakeCache()
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// answer sends a GET through the API strategy, as src/sw.ts does for a
|
||||
// network-first route; the strategy's own fetch is the network.
|
||||
export async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
// stubFetchThroughWorker replaces the global fetch with the page's view of
|
||||
// it behind the worker, routed as src/sw.ts routes it: a network-first path
|
||||
// from the API client goes through the API strategy, a network-only one
|
||||
// straight to network; the strategy's own fetch, a Request, is network too.
|
||||
// As in a browser, a worker that cannot answer is a network error to the
|
||||
// page. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetchThroughWorker(network: (request: Request) => Promise<Response>) {
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (input instanceof Request) return network(input)
|
||||
const request = new Request(new URL(String(input), origin), init)
|
||||
const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)
|
||||
if (route.strategy === 'network-only') return network(request)
|
||||
if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`)
|
||||
return answer(request).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user