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")
}
}