eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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
16func 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
31func TestParseOG(t *testing.T) {
32 page := `<html><head>
33 <title>Fallback Title</title>
34 <meta property="og:title" content="Real &amp; 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
53func 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
60func 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.
108func 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.
151func 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.
206func 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.
250func 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