单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
// subscription_test.go:多订阅容灾 / TTL 缓存 / stale-on-error 单测(httptest 环回,无外网)。
|
||
// 安全断言:失败摘要不含 URL。
|
||
package proxymanager
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestSubFetchOK(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
fmt.Fprint(w, sampleSub())
|
||
}))
|
||
defer srv.Close()
|
||
c := newSubscriptionCache([]string{srv.URL})
|
||
text, stale, err := c.Get(context.Background())
|
||
if err != nil || stale {
|
||
t.Fatalf("Get err=%v stale=%v", err, stale)
|
||
}
|
||
if !strings.Contains(string(text), "圣何塞") {
|
||
t.Fatal("正文不符")
|
||
}
|
||
// TTL 内二次 Get 不发请求(fetch 次数不变)
|
||
text2, stale2, _ := c.Get(context.Background())
|
||
if stale2 || string(text2) != string(text) {
|
||
t.Fatal("TTL 内应命中缓存")
|
||
}
|
||
}
|
||
|
||
func TestSubMultiURLFailover(t *testing.T) {
|
||
// 订阅 1 挂、订阅 2 活 → 容灾取 2
|
||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
fmt.Fprint(w, sampleSub())
|
||
}))
|
||
defer srv2.Close()
|
||
c := newSubscriptionCache([]string{"http://127.0.0.1:1/never", srv2.URL})
|
||
text, stale, err := c.Get(context.Background())
|
||
if err != nil || stale {
|
||
t.Fatalf("容灾失败: err=%v stale=%v", err, stale)
|
||
}
|
||
if len(text) == 0 {
|
||
t.Fatal("正文为空")
|
||
}
|
||
}
|
||
|
||
func TestSubAllFailStaleOnError(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
fmt.Fprint(w, sampleSub())
|
||
}))
|
||
c := newSubscriptionCache([]string{srv.URL})
|
||
if _, _, err := c.Get(context.Background()); err != nil {
|
||
t.Fatalf("首次拉取失败: %v", err)
|
||
}
|
||
// 强制过 TTL,且源已死 → stale-on-error
|
||
c.mu.Lock()
|
||
c.fetched = time.Now().Add(-subTTLCache - time.Second)
|
||
c.mu.Unlock()
|
||
srv.Close()
|
||
text, stale, err := c.Get(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("stale-on-error 不应报错: %v", err)
|
||
}
|
||
if !stale {
|
||
t.Fatal("应标记 stale")
|
||
}
|
||
if !strings.Contains(string(text), "圣何塞") {
|
||
t.Fatal("stale 正文应保留")
|
||
}
|
||
if !c.Stale() {
|
||
t.Fatal("Stale() 应为 true")
|
||
}
|
||
}
|
||
|
||
func TestSubAllFailNoCacheFailClosed(t *testing.T) {
|
||
c := newSubscriptionCache([]string{"http://127.0.0.1:1/never"})
|
||
_, _, err := c.Get(context.Background())
|
||
if err == nil {
|
||
t.Fatal("全部失败且无缓存应 fail-closed 报错")
|
||
}
|
||
if !strings.Contains(err.Error(), "fail-closed") {
|
||
t.Errorf("错误应标明 fail-closed: %v", err)
|
||
}
|
||
// 脱敏断言:错误摘要不含任何 URL
|
||
for _, e := range c.LastErrors() {
|
||
if strings.Contains(e, "http://") || strings.Contains(e, "127.0.0.1") {
|
||
t.Errorf("失败摘要泄漏 URL: %q", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSubRejectsNonHTTP(t *testing.T) {
|
||
c := newSubscriptionCache([]string{"file:///etc/passwd"})
|
||
_, _, err := c.Get(context.Background())
|
||
if err == nil {
|
||
t.Fatal("非 http(s) URL 应拒绝")
|
||
}
|
||
}
|
||
|
||
func TestSubCappedRead(t *testing.T) {
|
||
// 8MB 上限:超限拒绝
|
||
big := strings.Repeat("a", subMaxBytes+10)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
fmt.Fprint(w, big)
|
||
}))
|
||
defer srv.Close()
|
||
c := newSubscriptionCache([]string{srv.URL})
|
||
_, _, err := c.Get(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), "8MB") {
|
||
t.Fatalf("超 8MB 应拒绝, err=%v", err)
|
||
}
|
||
}
|
||
|
||
func TestSubURLNeverInError(t *testing.T) {
|
||
secretURL := "http://127.0.0.1:1/path?token=SECRET_TOKEN_XYZ"
|
||
c := newSubscriptionCache([]string{secretURL})
|
||
_, _, err := c.Get(context.Background())
|
||
if err == nil {
|
||
t.Fatal("应失败")
|
||
}
|
||
if strings.Contains(err.Error(), "SECRET_TOKEN_XYZ") {
|
||
t.Fatalf("错误信息泄漏订阅 URL: %v", err)
|
||
}
|
||
if !c.Stale() && len(c.LastErrors()) == 0 {
|
||
t.Fatal("应有失败摘要记录")
|
||
}
|
||
for _, e := range c.LastErrors() {
|
||
if strings.Contains(e, "SECRET_TOKEN_XYZ") || strings.Contains(e, "http") {
|
||
t.Errorf("摘要泄漏 URL: %q", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSubFetchViaBootstrapProxy(t *testing.T) {
|
||
// 直连目标不可达;请求必须打到 bootstrap 代理才拿得到正文。
|
||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
fmt.Fprint(w, sampleSub())
|
||
}))
|
||
defer proxy.Close()
|
||
t.Setenv("BROWSER_SERVER_SUB_FETCH_PROXY", proxy.URL)
|
||
c := newSubscriptionCache([]string{"http://127.0.0.1:1/never-direct"})
|
||
text, stale, err := c.Get(context.Background())
|
||
if err != nil || stale {
|
||
t.Fatalf("经 bootstrap 应成功: err=%v stale=%v", err, stale)
|
||
}
|
||
if !strings.Contains(string(text), "圣何塞") {
|
||
t.Fatal("正文不符")
|
||
}
|
||
}
|
||
|
||
func TestSubClientEmptyProxyDirect(t *testing.T) {
|
||
t.Setenv("BROWSER_SERVER_SUB_FETCH_PROXY", "")
|
||
c := newSubClient()
|
||
tr, ok := c.hc.Transport.(*http.Transport)
|
||
if !ok {
|
||
t.Fatal("Transport 类型不符")
|
||
}
|
||
if tr.Proxy != nil {
|
||
t.Fatal("缺省应直连(Proxy 未挂)")
|
||
}
|
||
}
|
||
|
||
func TestSubTTLCacheWindow(t *testing.T) {
|
||
var hits int
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hits++
|
||
fmt.Fprint(w, sampleSub())
|
||
}))
|
||
defer srv.Close()
|
||
c := newSubscriptionCache([]string{srv.URL})
|
||
for i := 0; i < 5; i++ {
|
||
if _, _, err := c.Get(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if hits != 1 {
|
||
t.Errorf("TTL 内 5 次 Get 应只拉 1 次, hits=%d", hits)
|
||
}
|
||
// 强制过期后再拉
|
||
c.mu.Lock()
|
||
c.fetched = time.Now().Add(-subTTLCache - time.Second)
|
||
c.mu.Unlock()
|
||
if _, _, err := c.Get(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if hits != 2 {
|
||
t.Errorf("过期后应重拉, hits=%d", hits)
|
||
}
|
||
}
|