package main import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" "time" ) func newTestLinksServer(t *testing.T) *httptest.Server { t.Helper() allowPrivatePreviewHosts = true // reuse the shared SSRF-guard test escape hatch linksInsecureSkipVerifyCert = true t.Cleanup(func() { allowPrivatePreviewHosts = false linksInsecureSkipVerifyCert = false }) l := newLinksServer() ts := httptest.NewServer(l.handler()) t.Cleanup(ts.Close) return ts } // fakeLchatInstance stands up an httptest TLS server that answers // /api/version like a real instance (probeLchatInstance always fetches // https://), so its shape check can pass — self-signed, hence // linksInsecureSkipVerifyCert in tests. func fakeLchatInstance(t *testing.T, build, version string) *httptest.Server { t.Helper() ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/version" { http.NotFound(w, r) return } json.NewEncoder(w).Encode(map[string]string{"build": build, "version": version}) })) t.Cleanup(ts.Close) return ts } func noRedirectClient() *http.Client { return &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} } func TestLinksRootRedirectsToMarketingSite(t *testing.T) { ts := newTestLinksServer(t) resp, err := noRedirectClient().Get(ts.URL + "/") if err != nil { t.Fatalf("GET /: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("status = %d, want 302", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != linksMarketingURL { t.Fatalf("Location = %q, want %q", loc, linksMarketingURL) } } func TestLinksArbitraryPathAlsoRedirectsToMarketingSite(t *testing.T) { ts := newTestLinksServer(t) resp, err := noRedirectClient().Get(ts.URL + "/whatever/else") if err != nil { t.Fatalf("GET: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != linksMarketingURL { t.Fatalf("status=%d location=%q", resp.StatusCode, resp.Header.Get("Location")) } } func TestLinksServesAppleAppSiteAssociation(t *testing.T) { ts := newTestLinksServer(t) resp, err := http.Get(ts.URL + "/.well-known/apple-app-site-association") if err != nil { t.Fatalf("GET aasa: %v", err) } defer resp.Body.Close() var body struct { Applinks struct { Details []struct { AppIDs []string `json:"appIDs"` Components []struct { Path string `json:"/"` } `json:"components"` } `json:"details"` } `json:"applinks"` } if json.NewDecoder(resp.Body).Decode(&body) != nil || len(body.Applinks.Details) != 1 { t.Fatalf("unexpected aasa body") } d := body.Applinks.Details[0] if len(d.AppIDs) != 1 || d.AppIDs[0] != appleAppID { t.Fatalf("appIDs = %v, want [%q]", d.AppIDs, appleAppID) } if len(d.Components) != 1 || d.Components[0].Path != "/l/*" { t.Fatalf("components = %v, want /l/*", d.Components) } } func TestLinksRedirectsToAVerifiedInstance(t *testing.T) { ts := newTestLinksServer(t) instance := fakeLchatInstance(t, "abc123", "def456") u, _ := url.Parse(instance.URL) resp, err := noRedirectClient().Get(ts.URL + "/l/" + u.Host) if err != nil { t.Fatalf("GET /l/: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("status = %d, want 302", resp.StatusCode) } want := "https://" + u.Host + "/" if loc := resp.Header.Get("Location"); loc != want { t.Fatalf("Location = %q, want %q (no fragment — the caller's own #l= must reattach on its own)", loc, want) } } func TestLinksRejectsUnverifiedHost(t *testing.T) { ts := newTestLinksServer(t) resp, err := noRedirectClient().Get(ts.URL + "/l/nothing-answers-here.invalid") if err != nil { t.Fatalf("GET: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.StatusCode) } } func TestLinksRejectsMalformedHostWithoutAnyNetworkAttempt(t *testing.T) { allowPrivatePreviewHosts = false // deliberately NOT the test escape hatch: this must 404 on shape alone l := newLinksServer() ts := httptest.NewServer(l.handler()) defer ts.Close() // "../.." isn't tested here — net/http's ServeMux cleans dot-segments out // of the path before a handler ever sees them, so it never reaches our // own validation at all (a different, earlier-stage protection). for _, bad := range []string{"not_a_host!!", "a..b", "-leading-dash", "no-dot-at-all"} { resp, err := noRedirectClient().Get(ts.URL + "/l/" + url.PathEscape(bad)) if err != nil { t.Fatalf("GET %q: %v", bad, err) } resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("host %q: status = %d, want 404", bad, resp.StatusCode) } } } func TestLinksVerifyCacheServesFreshHitsWithoutReprobing(t *testing.T) { allowPrivatePreviewHosts = true linksInsecureSkipVerifyCert = true defer func() { allowPrivatePreviewHosts = false linksInsecureSkipVerifyCert = false }() calls := 0 instance := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ json.NewEncoder(w).Encode(map[string]string{"build": "b", "version": "v"}) })) defer instance.Close() u, _ := url.Parse(instance.URL) c := newLinksVerifyCache() if !c.verify(u.Host) { t.Fatal("first verify should succeed") } if !c.verify(u.Host) { t.Fatal("second verify (cache hit) should still report ok") } if calls != 1 { t.Fatalf("expected exactly 1 live probe (second call served from cache), got %d", calls) } } func TestLinksVerifyCacheFailEntryRetriesSoonerThanFreshEntry(t *testing.T) { c := newLinksVerifyCache() c.entries["dead.example"] = linksCacheEntry{ok: false, checkedAt: time.Now().Add(-2 * time.Hour)} c.entries["alive.example"] = linksCacheEntry{ok: true, checkedAt: time.Now().Add(-2 * time.Hour)} // Both entries are 2h stale: the fail TTL (1h) has elapsed so "dead" is due // for a re-probe, but the fresh TTL (7d) hasn't for "alive" — it should // still serve the cached true without a network attempt. allowPrivatePreviewHosts = false // no real network should happen for the fresh hit if !c.verify("alive.example") { t.Fatal("a fresh (within 7d) cached ok=true entry should serve from cache, not re-probe") } } func TestLinksRateLimiterBlocksAFlood(t *testing.T) { ts := newTestLinksServer(t) var lastStatus int for i := 0; i < 40; i++ { resp, err := noRedirectClient().Get(ts.URL + "/l/nothing-answers-here.invalid") if err != nil { t.Fatalf("GET: %v", err) } lastStatus = resp.StatusCode resp.Body.Close() if lastStatus == http.StatusTooManyRequests { return } } t.Fatalf("expected a 429 within 40 rapid requests from one IP, last status was %d", lastStatus) }