| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "html" |
| 7 | "net" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | func TestIsPublicIP(t *testing.T) { |
| 17 | blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.1.1", "::1", "0.0.0.0", "100.64.0.1"} |
| 18 | for _, s := range blocked { |
| 19 | if isPublicIP(net.ParseIP(s)) { |
| 20 | t.Errorf("%s should be blocked", s) |
| 21 | } |
| 22 | } |
| 23 | public := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"} |
| 24 | for _, s := range public { |
| 25 | if !isPublicIP(net.ParseIP(s)) { |
| 26 | t.Errorf("%s should be allowed", s) |
| 27 | } |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func TestParseOG(t *testing.T) { |
| 32 | page := `<html><head> |
| 33 | <title>Fallback Title</title> |
| 34 | <meta property="og:title" content="Real & Title" /> |
| 35 | <meta name="description" content="A nice description"> |
| 36 | <meta property="og:image" content="/img/cover.png"> |
| 37 | </head><body>hi</body></html>` |
| 38 | pv := parseOG(page, "https://example.com/post") |
| 39 | if pv.Title != "Real & Title" { |
| 40 | t.Fatalf("title = %q", pv.Title) |
| 41 | } |
| 42 | if pv.Description != "A nice description" { |
| 43 | t.Fatalf("description = %q", pv.Description) |
| 44 | } |
| 45 | if pv.Image != "https://example.com/img/cover.png" { |
| 46 | t.Fatalf("image = %q (relative not resolved)", pv.Image) |
| 47 | } |
| 48 | if !pv.OK { |
| 49 | t.Fatal("should be OK") |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestParseOGTitleFallback(t *testing.T) { |
| 54 | pv := parseOG(`<head><title>Just A Title</title></head>`, "https://x.com") |
| 55 | if pv.Title != "Just A Title" || !pv.OK { |
| 56 | t.Fatalf("fallback title = %q ok=%v", pv.Title, pv.OK) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestUnfurlEndpoint(t *testing.T) { |
| 61 | ts, srv := newTestServer(t) |
| 62 | |
| 63 | // The preview target is on loopback (httptest), so allow private hosts here. |
| 64 | allowPrivatePreviewHosts = true |
| 65 | t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| 66 | |
| 67 | page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 68 | w.Header().Set("Content-Type", "text/html") |
| 69 | w.Write([]byte(`<head><meta property="og:title" content="Hello OG"><meta property="og:description" content="desc"></head>`)) |
| 70 | })) |
| 71 | t.Cleanup(page.Close) |
| 72 | |
| 73 | tok := mintDeviceToken(t, srv.db, "x") |
| 74 | |
| 75 | // No token → 403. |
| 76 | r, _ := http.Get(ts.URL + "/api/unfurl?url=" + page.URL) |
| 77 | if r.StatusCode != http.StatusForbidden { |
| 78 | t.Fatalf("unfurl no token: want 403, got %d", r.StatusCode) |
| 79 | } |
| 80 | |
| 81 | // Bad URL → 400. |
| 82 | r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=notaurl") |
| 83 | if r.StatusCode != http.StatusBadRequest { |
| 84 | t.Fatalf("unfurl bad url: want 400, got %d", r.StatusCode) |
| 85 | } |
| 86 | |
| 87 | // Good fetch → preview returned and cached. |
| 88 | r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=" + page.URL) |
| 89 | if r.StatusCode != http.StatusOK { |
| 90 | t.Fatalf("unfurl: %d", r.StatusCode) |
| 91 | } |
| 92 | var pv LinkPreview |
| 93 | json.NewDecoder(r.Body).Decode(&pv) |
| 94 | if !pv.OK || pv.Title != "Hello OG" { |
| 95 | t.Fatalf("preview = %+v", pv) |
| 96 | } |
| 97 | |
| 98 | // It should now be in the cache. |
| 99 | if _, ok, _ := srv.db.getLinkPreview(page.URL, ""); !ok { |
| 100 | t.Fatal("preview was not cached") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // A request that arrives while the same URL's fetch is in flight waits for it |
| 105 | // and gets the real card — not an empty stub. This is the poster/recipient |
| 106 | // race: the recipient renders a just-posted link milliseconds after the |
| 107 | // poster, lands mid-fetch, and used to be the one member with no preview. |
| 108 | func TestUnfurlConcurrentRequestsShareFetch(t *testing.T) { |
| 109 | _, srv := newTestServer(t) |
| 110 | |
| 111 | allowPrivatePreviewHosts = true |
| 112 | t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| 113 | |
| 114 | // The page blocks until released, holding the first fetch in flight; a |
| 115 | // second fetch would panic the double-close, so one fetch is also asserted. |
| 116 | var started sync.Once |
| 117 | firstFetch := make(chan struct{}) |
| 118 | release := make(chan struct{}) |
| 119 | page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 120 | started.Do(func() { close(firstFetch) }) |
| 121 | <-release |
| 122 | w.Header().Set("Content-Type", "text/html") |
| 123 | w.Write([]byte(`<head><meta property="og:title" content="Raced"></head>`)) |
| 124 | })) |
| 125 | t.Cleanup(page.Close) |
| 126 | |
| 127 | var poster, recipient LinkPreview |
| 128 | var wg sync.WaitGroup |
| 129 | wg.Add(2) |
| 130 | go func() { defer wg.Done(); poster = srv.fetchAndCachePreview(page.URL, "") }() |
| 131 | <-firstFetch // the poster's fetch is now in flight |
| 132 | go func() { defer wg.Done(); recipient = srv.fetchAndCachePreview(page.URL, "") }() |
| 133 | // Give the recipient's call time to reach the in-flight path, then let the |
| 134 | // page respond. (If it instead arrives after the fetch completes, it reads |
| 135 | // the cache — either way the assertion below is what members experience.) |
| 136 | time.Sleep(50 * time.Millisecond) |
| 137 | close(release) |
| 138 | wg.Wait() |
| 139 | |
| 140 | if !poster.OK || poster.Title != "Raced" { |
| 141 | t.Fatalf("poster preview = %+v", poster) |
| 142 | } |
| 143 | if !recipient.OK || recipient.Title != "Raced" { |
| 144 | t.Fatalf("recipient preview = %+v (the mid-fetch stub bug)", recipient) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // The member's Accept-Language rides along to the unfurled page (so a box in |
| 149 | // Finland doesn't produce Finnish cards for Irish members), and cards fetched |
| 150 | // under different languages cache as separate rows. |
| 151 | func TestUnfurlLanguage(t *testing.T) { |
| 152 | ts, srv := newTestServer(t) |
| 153 | |
| 154 | allowPrivatePreviewHosts = true |
| 155 | t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| 156 | |
| 157 | // The page negotiates: its title echoes the Accept-Language it was sent. |
| 158 | page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 159 | w.Header().Set("Content-Type", "text/html") |
| 160 | fmt.Fprintf(w, `<head><meta property="og:title" content="lang=%s"></head>`, |
| 161 | html.EscapeString(r.Header.Get("Accept-Language"))) |
| 162 | })) |
| 163 | t.Cleanup(page.Close) |
| 164 | |
| 165 | tok := mintDeviceToken(t, srv.db, "x") |
| 166 | |
| 167 | fetch := func(acceptLang string) LinkPreview { |
| 168 | t.Helper() |
| 169 | req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl?token="+tok+"&url="+page.URL, nil) |
| 170 | if acceptLang != "" { |
| 171 | req.Header.Set("Accept-Language", acceptLang) |
| 172 | } |
| 173 | r, err := http.DefaultClient.Do(req) |
| 174 | if err != nil { |
| 175 | t.Fatalf("unfurl: %v", err) |
| 176 | } |
| 177 | defer r.Body.Close() |
| 178 | var pv LinkPreview |
| 179 | json.NewDecoder(r.Body).Decode(&pv) |
| 180 | return pv |
| 181 | } |
| 182 | |
| 183 | // A regioned tag is forwarded with its base-language fallback. |
| 184 | if pv := fetch("en-IE,en;q=0.9,fi;q=0.8"); pv.Title != "lang=en-IE, en;q=0.8" { |
| 185 | t.Fatalf("en-IE preview = %+v", pv) |
| 186 | } |
| 187 | // A different language is a separate fetch, not the cached en-IE card. |
| 188 | if pv := fetch("fi"); pv.Title != "lang=fi" { |
| 189 | t.Fatalf("fi preview = %+v", pv) |
| 190 | } |
| 191 | // Both variants sit in the cache independently; neither sees the other's. |
| 192 | if pv, ok, _ := srv.db.getLinkPreview(page.URL, "en-IE"); !ok || pv.Title != "lang=en-IE, en;q=0.8" { |
| 193 | t.Fatalf("cached en-IE = ok=%v %+v", ok, pv) |
| 194 | } |
| 195 | if pv, ok, _ := srv.db.getLinkPreview(page.URL, "fi"); !ok || pv.Title != "lang=fi" { |
| 196 | t.Fatalf("cached fi = ok=%v %+v", ok, pv) |
| 197 | } |
| 198 | // Garbage headers degrade to "no preference", never to a poisoned key. |
| 199 | if got := previewLang(`" onload="x`); got != "" { |
| 200 | t.Fatalf("previewLang(garbage) = %q", got) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // The cache must hold no plaintext at rest: rows are found by a salted hash of |
| 205 | // the URL and opened only with a key derived from the URL itself. |
| 206 | func TestLinkPreviewSealedAtRest(t *testing.T) { |
| 207 | db := newTestDB(t) |
| 208 | pv := LinkPreview{ |
| 209 | URL: "https://example.com/a-private-doc", Title: "Quarterly Numbers", |
| 210 | Description: "Do not share", Image: "https://example.com/chart.png", |
| 211 | OK: true, FetchedAt: time.Now().Unix(), |
| 212 | } |
| 213 | if err := db.putLinkPreview(pv, ""); err != nil { |
| 214 | t.Fatalf("putLinkPreview: %v", err) |
| 215 | } |
| 216 | |
| 217 | // Round-trip. |
| 218 | got, ok, err := db.getLinkPreview(pv.URL, "") |
| 219 | if err != nil || !ok { |
| 220 | t.Fatalf("getLinkPreview: ok=%v err=%v", ok, err) |
| 221 | } |
| 222 | if got.Title != pv.Title || got.Description != pv.Description || got.Image != pv.Image || !got.OK { |
| 223 | t.Fatalf("round-trip = %+v", got) |
| 224 | } |
| 225 | |
| 226 | // At rest: neither the URL nor any card field appears in the stored row. |
| 227 | var urlHash, blob string |
| 228 | if err := db.sql.QueryRow("SELECT url_hash, blob FROM link_previews_sealed").Scan(&urlHash, &blob); err != nil { |
| 229 | t.Fatalf("raw row: %v", err) |
| 230 | } |
| 231 | stored := urlHash + blob |
| 232 | for _, secret := range []string{pv.URL, "example.com", "Quarterly", "Do not share", "chart.png"} { |
| 233 | if strings.Contains(stored, secret) { |
| 234 | t.Fatalf("plaintext %q found in the stored row", secret) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // A different URL neither finds nor opens the row. |
| 239 | if _, ok, _ := db.getLinkPreview("https://example.com/other", ""); ok { |
| 240 | t.Fatal("wrong URL matched a cached row") |
| 241 | } |
| 242 | salt, _ := db.previewSalt() |
| 243 | if _, ok := openPreview(salt, "https://example.com/other", blob); ok { |
| 244 | t.Fatal("wrong URL decrypted the blob") |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // Old rows age out on the put-time sweep, and the legacy plaintext table is |
| 249 | // dropped by migration. |
| 250 | func TestLinkPreviewEvictionAndLegacyDrop(t *testing.T) { |
| 251 | dir := t.TempDir() |
| 252 | path := dir + "/old.db" |
| 253 | old, err := openDB(path) |
| 254 | if err != nil { |
| 255 | t.Fatalf("openDB: %v", err) |
| 256 | } |
| 257 | // Seed a legacy plaintext table as an old binary would have left it. |
| 258 | if _, err := old.sql.Exec(`CREATE TABLE IF NOT EXISTS link_previews ( |
| 259 | url TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', |
| 260 | image TEXT NOT NULL DEFAULT '', ok INTEGER NOT NULL DEFAULT 0, fetched_at INTEGER NOT NULL); |
| 261 | INSERT INTO link_previews VALUES ('https://example.com/x', 'Plain Title', 'plain', '', 1, 0)`); err != nil { |
| 262 | t.Fatalf("seed legacy: %v", err) |
| 263 | } |
| 264 | old.sql.Close() |
| 265 | |
| 266 | db, err := openDB(path) |
| 267 | if err != nil { |
| 268 | t.Fatalf("reopen: %v", err) |
| 269 | } |
| 270 | defer db.sql.Close() |
| 271 | var n int |
| 272 | if err := db.sql.QueryRow( |
| 273 | "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='link_previews'").Scan(&n); err != nil { |
| 274 | t.Fatalf("sqlite_master: %v", err) |
| 275 | } |
| 276 | if n != 0 { |
| 277 | t.Fatal("legacy plaintext link_previews table survived migration") |
| 278 | } |
| 279 | |
| 280 | // Eviction: an ancient row disappears when the next put sweeps. |
| 281 | stale := LinkPreview{URL: "https://example.com/stale", Title: "old", OK: true, |
| 282 | FetchedAt: time.Now().Unix() - previewMaxAge - 60} |
| 283 | if err := db.putLinkPreview(stale, ""); err != nil { |
| 284 | t.Fatalf("put stale: %v", err) |
| 285 | } |
| 286 | fresh := LinkPreview{URL: "https://example.com/fresh", Title: "new", OK: true, |
| 287 | FetchedAt: time.Now().Unix()} |
| 288 | if err := db.putLinkPreview(fresh, ""); err != nil { |
| 289 | t.Fatalf("put fresh: %v", err) |
| 290 | } |
| 291 | if _, ok, _ := db.getLinkPreview(stale.URL, ""); ok { |
| 292 | t.Fatal("stale row survived the put-time sweep") |
| 293 | } |
| 294 | if _, ok, _ := db.getLinkPreview(fresh.URL, ""); !ok { |
| 295 | t.Fatal("fresh row missing") |
| 296 | } |
| 297 | } |
| 298 | |