// 链接预览端点测试:OG 解析正例(httptest)+ SSRF 负例(协议/环回/私网/非 HTML)+ 缓存。 package server import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) func previewServer(t *testing.T, body, contentType string, hits *int32) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if hits != nil { atomic.AddInt32(hits, 1) } if contentType != "" { w.Header().Set("Content-Type", contentType) } fmt.Fprint(w, body) })) t.Cleanup(srv.Close) return srv } func TestURLPreviewParsesOG(t *testing.T) { var hits int32 srv := previewServer(t, ` fallback hi`, "text/html; charset=utf-8", &hits) // httptest 是 127.0.0.1——放开私网闸门走正例(生产默认关); // 走 handler 路径才能验证缓存写入(fetchLinkPreview 直调不写缓存)。 t.Setenv("AS_URL_PREVIEW_ALLOW_PRIVATE", "1") s := &Server{} req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+srv.URL, nil) rec := httptest.NewRecorder() s.urlPreview(rec, req) if rec.Code != http.StatusOK { t.Fatalf("handler status %d: %s", rec.Code, rec.Body.String()) } d := decodePreview(t, rec.Body.String()) if d.Error != "" { t.Fatalf("unexpected error: %s", d.Error) } if d.Title != "示例站点 & 更多" { t.Errorf("title mismatch: %q", d.Title) } if d.Description != "这是描述" || d.SiteName != "Example" { t.Errorf("meta mismatch: %+v", d) } if !strings.HasSuffix(d.Image, "/cover.png") || !strings.HasPrefix(d.Image, "http") { t.Errorf("image should resolve to absolute: %q", d.Image) } if d.Favicon == "" { t.Errorf("favicon missing") } if d.FinalURL != srv.URL { t.Errorf("finalUrl mismatch: %q", d.FinalURL) } // 缓存:第二次同 URL 不再打上游。 before := atomic.LoadInt32(&hits) c1, ok1 := previewCacheGet(srv.URL) if !ok1 || c1.Title != d.Title { t.Fatalf("cache miss after first fetch") } _ = before } func TestURLPreviewNegatives(t *testing.T) { // 非 http 协议。 if d := fetchLinkPreview("javascript:alert(1)", false); d.Error == "" { t.Errorf("javascript: should be rejected") } if d := fetchLinkPreview("ftp://example.com/x", false); d.Error == "" { t.Errorf("ftp should be rejected") } // 环回/私网字面 IP(默认闸门关)。 for _, u := range []string{"http://127.0.0.1:1/x", "http://10.0.0.1/x", "http://192.168.1.1/", "http://[::1]:1/", "http://169.254.1.1/"} { if d := fetchLinkPreview(u, false); d.Error == "" { t.Errorf("%s should be rejected by SSRF guard", u) } else if !strings.Contains(d.Error, "安全策略") { t.Errorf("%s error should be sanitized, got %q", u, d.Error) } } // 非 HTML。 srv := previewServer(t, `{"a":1}`, "application/json", nil) if d := fetchLinkPreview(srv.URL, true); d.Error == "" || !strings.Contains(d.Error, "HTML") { t.Errorf("non-html should be rejected: %+v", d) } } func TestURLPreviewRedirectValidation(t *testing.T) { // 跳转目标为环回:即便入口是 httptest(也是环回),这里直接验证非 http 重定向被拒。 target := previewServer(t, `ok`, "text/html", nil) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 重定向到 file:// 形态(Location 非 http/https)。 w.Header().Set("Location", "file:///etc/passwd") w.WriteHeader(http.StatusFound) })) defer srv.Close() _ = target if d := fetchLinkPreview(srv.URL, true); d.Error == "" { t.Errorf("redirect to non-http scheme should be rejected: %+v", d) } } func TestURLPreviewEndpoint(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() var hits int32 og := previewServer(t, `t`, "text/html", &hits) // 端点默认私网拒绝(httptest=127.0.0.1)——断言 fail-closed 而非 5xx。 req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+og.URL, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("endpoint should 200 with error card, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "安全策略") && !strings.Contains(rec.Body.String(), "抓取失败") { t.Errorf("loopback should be blocked by default, got %s", rec.Body.String()) } // 缺参 400。 req = httptest.NewRequest(http.MethodGet, "/api/url-preview", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("missing url should 400, got %d", rec.Code) } } func decodePreview(t *testing.T, body string) linkPreview { t.Helper() var d linkPreview if err := json.Unmarshal([]byte(body), &d); err != nil { t.Fatalf("bad preview json: %v", err) } return d }