package main import ( "encoding/json" "fmt" "html" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) func TestIsPublicIP(t *testing.T) { 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"} for _, s := range blocked { if isPublicIP(net.ParseIP(s)) { t.Errorf("%s should be blocked", s) } } public := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"} for _, s := range public { if !isPublicIP(net.ParseIP(s)) { t.Errorf("%s should be allowed", s) } } } func TestParseOG(t *testing.T) { page := ` Fallback Title hi` pv := parseOG(page, "https://example.com/post") if pv.Title != "Real & Title" { t.Fatalf("title = %q", pv.Title) } if pv.Description != "A nice description" { t.Fatalf("description = %q", pv.Description) } if pv.Image != "https://example.com/img/cover.png" { t.Fatalf("image = %q (relative not resolved)", pv.Image) } if !pv.OK { t.Fatal("should be OK") } } func TestParseOGTitleFallback(t *testing.T) { pv := parseOG(`Just A Title`, "https://x.com") if pv.Title != "Just A Title" || !pv.OK { t.Fatalf("fallback title = %q ok=%v", pv.Title, pv.OK) } } func TestUnfurlEndpoint(t *testing.T) { ts, srv := newTestServer(t) // The preview target is on loopback (httptest), so allow private hosts here. allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(``)) })) t.Cleanup(page.Close) tok := mintDeviceToken(t, srv.db, "x") // No token → 403. r, _ := http.Get(ts.URL + "/api/unfurl?url=" + page.URL) if r.StatusCode != http.StatusForbidden { t.Fatalf("unfurl no token: want 403, got %d", r.StatusCode) } // Bad URL → 400. r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=notaurl") if r.StatusCode != http.StatusBadRequest { t.Fatalf("unfurl bad url: want 400, got %d", r.StatusCode) } // Good fetch → preview returned and cached. r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=" + page.URL) if r.StatusCode != http.StatusOK { t.Fatalf("unfurl: %d", r.StatusCode) } var pv LinkPreview json.NewDecoder(r.Body).Decode(&pv) if !pv.OK || pv.Title != "Hello OG" { t.Fatalf("preview = %+v", pv) } // It should now be in the cache. if _, ok, _ := srv.db.getLinkPreview(page.URL, ""); !ok { t.Fatal("preview was not cached") } } // A request that arrives while the same URL's fetch is in flight waits for it // and gets the real card — not an empty stub. This is the poster/recipient // race: the recipient renders a just-posted link milliseconds after the // poster, lands mid-fetch, and used to be the one member with no preview. func TestUnfurlConcurrentRequestsShareFetch(t *testing.T) { _, srv := newTestServer(t) allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) // The page blocks until released, holding the first fetch in flight; a // second fetch would panic the double-close, so one fetch is also asserted. var started sync.Once firstFetch := make(chan struct{}) release := make(chan struct{}) page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started.Do(func() { close(firstFetch) }) <-release w.Header().Set("Content-Type", "text/html") w.Write([]byte(``)) })) t.Cleanup(page.Close) var poster, recipient LinkPreview var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); poster = srv.fetchAndCachePreview(page.URL, "") }() <-firstFetch // the poster's fetch is now in flight go func() { defer wg.Done(); recipient = srv.fetchAndCachePreview(page.URL, "") }() // Give the recipient's call time to reach the in-flight path, then let the // page respond. (If it instead arrives after the fetch completes, it reads // the cache — either way the assertion below is what members experience.) time.Sleep(50 * time.Millisecond) close(release) wg.Wait() if !poster.OK || poster.Title != "Raced" { t.Fatalf("poster preview = %+v", poster) } if !recipient.OK || recipient.Title != "Raced" { t.Fatalf("recipient preview = %+v (the mid-fetch stub bug)", recipient) } } // The member's Accept-Language rides along to the unfurled page (so a box in // Finland doesn't produce Finnish cards for Irish members), and cards fetched // under different languages cache as separate rows. func TestUnfurlLanguage(t *testing.T) { ts, srv := newTestServer(t) allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) // The page negotiates: its title echoes the Accept-Language it was sent. page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, ``, html.EscapeString(r.Header.Get("Accept-Language"))) })) t.Cleanup(page.Close) tok := mintDeviceToken(t, srv.db, "x") fetch := func(acceptLang string) LinkPreview { t.Helper() req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl?token="+tok+"&url="+page.URL, nil) if acceptLang != "" { req.Header.Set("Accept-Language", acceptLang) } r, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("unfurl: %v", err) } defer r.Body.Close() var pv LinkPreview json.NewDecoder(r.Body).Decode(&pv) return pv } // A regioned tag is forwarded with its base-language fallback. if pv := fetch("en-IE,en;q=0.9,fi;q=0.8"); pv.Title != "lang=en-IE, en;q=0.8" { t.Fatalf("en-IE preview = %+v", pv) } // A different language is a separate fetch, not the cached en-IE card. if pv := fetch("fi"); pv.Title != "lang=fi" { t.Fatalf("fi preview = %+v", pv) } // Both variants sit in the cache independently; neither sees the other's. if pv, ok, _ := srv.db.getLinkPreview(page.URL, "en-IE"); !ok || pv.Title != "lang=en-IE, en;q=0.8" { t.Fatalf("cached en-IE = ok=%v %+v", ok, pv) } if pv, ok, _ := srv.db.getLinkPreview(page.URL, "fi"); !ok || pv.Title != "lang=fi" { t.Fatalf("cached fi = ok=%v %+v", ok, pv) } // Garbage headers degrade to "no preference", never to a poisoned key. if got := previewLang(`" onload="x`); got != "" { t.Fatalf("previewLang(garbage) = %q", got) } } // The cache must hold no plaintext at rest: rows are found by a salted hash of // the URL and opened only with a key derived from the URL itself. func TestLinkPreviewSealedAtRest(t *testing.T) { db := newTestDB(t) pv := LinkPreview{ URL: "https://example.com/a-private-doc", Title: "Quarterly Numbers", Description: "Do not share", Image: "https://example.com/chart.png", OK: true, FetchedAt: time.Now().Unix(), } if err := db.putLinkPreview(pv, ""); err != nil { t.Fatalf("putLinkPreview: %v", err) } // Round-trip. got, ok, err := db.getLinkPreview(pv.URL, "") if err != nil || !ok { t.Fatalf("getLinkPreview: ok=%v err=%v", ok, err) } if got.Title != pv.Title || got.Description != pv.Description || got.Image != pv.Image || !got.OK { t.Fatalf("round-trip = %+v", got) } // At rest: neither the URL nor any card field appears in the stored row. var urlHash, blob string if err := db.sql.QueryRow("SELECT url_hash, blob FROM link_previews_sealed").Scan(&urlHash, &blob); err != nil { t.Fatalf("raw row: %v", err) } stored := urlHash + blob for _, secret := range []string{pv.URL, "example.com", "Quarterly", "Do not share", "chart.png"} { if strings.Contains(stored, secret) { t.Fatalf("plaintext %q found in the stored row", secret) } } // A different URL neither finds nor opens the row. if _, ok, _ := db.getLinkPreview("https://example.com/other", ""); ok { t.Fatal("wrong URL matched a cached row") } salt, _ := db.previewSalt() if _, ok := openPreview(salt, "https://example.com/other", blob); ok { t.Fatal("wrong URL decrypted the blob") } } // Old rows age out on the put-time sweep, and the legacy plaintext table is // dropped by migration. func TestLinkPreviewEvictionAndLegacyDrop(t *testing.T) { dir := t.TempDir() path := dir + "/old.db" old, err := openDB(path) if err != nil { t.Fatalf("openDB: %v", err) } // Seed a legacy plaintext table as an old binary would have left it. if _, err := old.sql.Exec(`CREATE TABLE IF NOT EXISTS link_previews ( url TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', image TEXT NOT NULL DEFAULT '', ok INTEGER NOT NULL DEFAULT 0, fetched_at INTEGER NOT NULL); INSERT INTO link_previews VALUES ('https://example.com/x', 'Plain Title', 'plain', '', 1, 0)`); err != nil { t.Fatalf("seed legacy: %v", err) } old.sql.Close() db, err := openDB(path) if err != nil { t.Fatalf("reopen: %v", err) } defer db.sql.Close() var n int if err := db.sql.QueryRow( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='link_previews'").Scan(&n); err != nil { t.Fatalf("sqlite_master: %v", err) } if n != 0 { t.Fatal("legacy plaintext link_previews table survived migration") } // Eviction: an ancient row disappears when the next put sweeps. stale := LinkPreview{URL: "https://example.com/stale", Title: "old", OK: true, FetchedAt: time.Now().Unix() - previewMaxAge - 60} if err := db.putLinkPreview(stale, ""); err != nil { t.Fatalf("put stale: %v", err) } fresh := LinkPreview{URL: "https://example.com/fresh", Title: "new", OK: true, FetchedAt: time.Now().Unix()} if err := db.putLinkPreview(fresh, ""); err != nil { t.Fatalf("put fresh: %v", err) } if _, ok, _ := db.getLinkPreview(stale.URL, ""); ok { t.Fatal("stale row survived the put-time sweep") } if _, ok, _ := db.getLinkPreview(fresh.URL, ""); !ok { t.Fatal("fresh row missing") } }