package m3u8ticket import ( "reflect" "strings" "testing" "time" "91porn-server/app/appg" "91porn-server/models/v/vidmod" ) const testKey = "unit-test-h5-m3u8-ticket-key-32byteslong!!" func setKey(t *testing.T, key string) { t.Helper() conf := &appg.GlobalConfig{} conf.Base.H5M3u8TicketKey = key appg.Conf = conf } // signedWith 直接用给定票据构造带票地址(单段 token),供构造过期等异常场景。 func signedWith(t *testing.T, tk *Ticket, ip, ua string) string { t.Helper() token, err := encodeTicket(tk, ip, ua, testKey) if err != nil { t.Fatalf("encodeTicket: %v", err) } return "/" + pathScheme + "/" + token } // tamperToken 翻转带票地址里 token 的末位字符,用于构造被篡改的密文。 func tamperToken(signed string) string { // 篡改 token 主体(跳过展示用 .m3u8 后缀)的末位字符,模拟密文被改。 s := strings.TrimSuffix(signed, ".m3u8") if len(s) == 0 { return signed } b := []byte(s) if b[len(b)-1] == 'A' { b[len(b)-1] = 'B' } else { b[len(b)-1] = 'A' } return string(b) + ".m3u8" } func TestSignVerifyRoundTrip(t *testing.T) { setKey(t, testKey) const ( uid = uint64(10086) path = "bktuserup/sp/9o/06/g7/fn/894552ffed374e63b1a4d4f0d86e7e8c.m3u8" ip = "1.2.3.4" ) signed := BuildSignedURL(uid, path, ip, "ua", true, false) if signed == "" { t.Fatal("expected non-empty signed url") } if !strings.HasPrefix(signed, "JHA-") || !strings.Contains(signed, "/"+pathScheme+"/") { t.Fatalf("signed url should be JHA-/tk1/..., got %q", signed) } if !strings.HasSuffix(signed, ".m3u8") { t.Fatalf("signed url should end with .m3u8, got %q", signed) } real, tk, ok := VerifyPath(signed, ip, "ua") if !ok { t.Fatal("valid ticket should pass VerifyPath") } if real != "/"+path { t.Fatalf("realSource mismatch: got %q want %q", real, "/"+path) } if tk.UserID != uid || !tk.Playable || tk.Preview { t.Fatalf("ticket payload wrong: %+v", tk) } } func TestVerifyRejectsTamperedPath(t *testing.T) { setKey(t, testKey) signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false) // 真实 path 已加密进 token,无法替换 path;篡改 token 任一字节都必须导致解密/校验失败。 if _, _, ok := VerifyPath(tamperToken(signed), "1.1.1.1", ""); ok { t.Fatal("tampered ticket must not validate") } } func TestVerifyRejectsWrongIP(t *testing.T) { setKey(t, testKey) signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false) if _, _, ok := VerifyPath(signed, "9.9.9.9", ""); ok { t.Fatal("ticket must not validate from a different IP") } } func TestVerifyRejectsExpired(t *testing.T) { setKey(t, testKey) tk := &Ticket{ UserID: 1, Path: "v3/av/a.m3u8", ExpireAt: time.Now().Add(-time.Minute).Unix(), // 已过期 Playable: true, } if _, _, ok := VerifyPath(signedWith(t, tk, "1.1.1.1", ""), "1.1.1.1", ""); ok { t.Fatal("expired ticket must not validate") } } func TestVerifyRejectsWrongKey(t *testing.T) { setKey(t, testKey) signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false) setKey(t, "another-key-totally-different-000000000000") if _, _, ok := VerifyPath(signed, "1.1.1.1", ""); ok { t.Fatal("ticket signed with a different key must not validate") } } func TestVerifyRejectsGarbageAndBarePath(t *testing.T) { setKey(t, testKey) cases := []string{ "", "/bktuserup/sp/a.m3u8", // 裸 path,无票据前缀 "/tk1//bktuserup/sp/a.m3u8", // 空票据段 "/tk1/not-base64!!/v3/av/a.m3u8", // 非法 base64 "/tk1/YWJj/v3/av/a.m3u8", // 可解码但非合法密文 } for _, in := range cases { if _, _, ok := VerifyPath(in, "1.1.1.1", ""); ok { t.Fatalf("input %q must not validate", in) } } } func TestDisabledWhenNoKey(t *testing.T) { setKey(t, "") if Enabled() { t.Fatal("must be disabled without key") } if got := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false); got != "" { t.Fatalf("must not sign without key, got %q", got) } if _, _, ok := VerifyPath("/tk1/x/v3/av/a.m3u8", "1.1.1.1", ""); ok { t.Fatal("must not validate without key") } } // TestStripTicket 验证非严格接口的还原:带票地址(含过期)解出真实 path、明文原样、未配密钥原样。 func TestStripTicket(t *testing.T) { setKey(t, testKey) const realPath = "bktuserup/sp/a.m3u8" // 带票地址 -> 解出真实 path signed := BuildSignedURL(1, realPath, "1.1.1.1", "", true, false) if got := StripTicket(signed); got != "/"+realPath { t.Fatalf("StripTicket(signed) = %q, want /%s", got, realPath) } // 过期票也应还原(StripTicket 不校验过期/IP) expired := signedWith(t, &Ticket{UserID: 1, Path: realPath, ExpireAt: time.Now().Add(-time.Hour).Unix(), Playable: true}, "9.9.9.9", "") if got := StripTicket(expired); got != "/"+realPath { t.Fatalf("StripTicket(expired) = %q, want /%s (no expiry check)", got, realPath) } // 裸明文 path 原样返回 if got := StripTicket("/" + realPath); got != "/"+realPath { t.Fatalf("StripTicket(plain) = %q, want unchanged", got) } // 未配置密钥时原样返回 setKey(t, "") if got := StripTicket(signed); got != signed { t.Fatalf("StripTicket without key should be unchanged, got %q", got) } } func TestSignRejectsNonM3u8AndZeroUser(t *testing.T) { setKey(t, testKey) if got := BuildSignedURL(1, "v3/av/a.mp4", "1.1.1.1", "", true, false); got != "" { t.Fatalf("non-m3u8 must not be signed, got %q", got) } if got := BuildSignedURL(0, "v3/av/a.m3u8", "1.1.1.1", "", true, false); got != "" { t.Fatalf("zero user must not be signed, got %q", got) } } func TestNormalizePath(t *testing.T) { a, okA := normalizePath("/v3/av/a.m3u8") b, okB := normalizePath("v3/av/a.m3u8") if !okA || !okB || a != b { t.Fatalf("leading slash should normalize equally: %q ok=%v vs %q ok=%v", a, okA, b, okB) } bad := []string{"", "http://x/a.m3u8", "https://x/a.m3u8", "../a.m3u8", `v3\a.m3u8`, "v3/%2e%2e/a.m3u8", "v3/av/a.mp4"} for _, in := range bad { if _, ok := normalizePath(in); ok { t.Fatalf("path %q should be rejected", in) } } } func TestSignedURLPreservesLeadingSlash(t *testing.T) { setKey(t, testKey) // 无前导斜杠输入 -> 无前导斜杠输出(与示例 sourceURL 形态一致)。 if got := BuildSignedURL(1, "bktuserup/sp/a.m3u8", "1.1.1.1", "", true, false); strings.HasPrefix(got, "/") { t.Fatalf("should not add leading slash, got %q", got) } // 有前导斜杠输入 -> 保留前导斜杠(应用前缀在斜杠之后)。 if got := BuildSignedURL(1, "/bktuserup/sp/a.m3u8", "1.1.1.1", "", true, false); !strings.HasPrefix(got, "/"+appPrefix()) { t.Fatalf("should preserve leading slash, got %q", got) } } // TestSignedURLHasAppPrefix 验证带票地址最前面带 JHA-{appid} 前缀,且验票/StripTicket 能剥前缀还原。 func TestSignedURLHasAppPrefix(t *testing.T) { setKey(t, testKey) const realPath = "bktuserup/sp/a.m3u8" signed := BuildSignedURL(1, realPath, "1.1.1.1", "", true, false) if !strings.HasPrefix(signed, appPrefix()) { t.Fatalf("signed url should start with %q, got %q", appPrefix(), signed) } if real, _, ok := VerifyPath(signed, "1.1.1.1", ""); !ok || real != "/"+realPath { t.Fatalf("VerifyPath with app prefix: ok=%v real=%q", ok, real) } if got := StripTicket(signed); got != "/"+realPath { t.Fatalf("StripTicket with app prefix = %q, want /%s", got, realPath) } } // TestFallbackSignsNestedVideoInfo 验证反射兜底能穿过未实现 M3u8Signable 的外层 wrapper -> 切片 -> 指针, // 就地改写 VideoInfo,且改写后的地址可被 VerifyPath 还原并校验通过。 func TestFallbackSignsNestedVideoInfo(t *testing.T) { setKey(t, testKey) v1 := &vidmod.VideoInfo{} v1.SourceURL = "bktuserup/sp/a.m3u8" v1.H265Url = "bktuserup/sp/a265.m3u8" v1.PreviewURL = "bktuserup/sp/p.m3u8" // 匿名 wrapper 不实现 M3u8Signable,signData 会落到反射兜底 signByReflect。 wrapper := struct{ List []*vidmod.VideoInfo }{List: []*vidmod.VideoInfo{v1}} signData(&ticketSigner{userID: 1, ip: "1.1.1.1", ua: "ua"}, &wrapper) for _, raw := range []string{v1.SourceURL, v1.H265Url, v1.PreviewURL} { if !strings.HasPrefix(raw, "JHA-") { t.Fatalf("field not signed: %q", raw) } if _, _, ok := VerifyPath("/"+raw, "1.1.1.1", "ua"); !ok { t.Fatalf("signed field should verify: %q", raw) } } } // TestFallbackSignsSliceOfValues 验证 []VideoInfoResp 值切片元素(可寻址)也能被兜底改写。 func TestFallbackSignsSliceOfValues(t *testing.T) { setKey(t, testKey) list := []vidmod.VideoInfoResp{{SourceURL: "bktuserup/sp/a.m3u8"}} signByReflect(&ticketSigner{userID: 2, ip: "2.2.2.2", ua: "ua"}, reflect.ValueOf(&list), 0) if !strings.HasPrefix(list[0].SourceURL, "JHA-") { t.Fatal("value-slice element SourceURL should have been signed") } } // TestSignDataUsesInterface 验证实现了 M3u8Signable 的响应体走零反射的接口分支即可就地签票 // (含 vidmod.VideoInfo 的三个地址字段与 previewURL 的试看语义)。 func TestSignDataUsesInterface(t *testing.T) { setKey(t, testKey) v := &vidmod.VideoInfo{} v.SourceURL = "bktuserup/sp/a.m3u8" v.H265Url = "bktuserup/sp/a265.m3u8" v.PreviewURL = "bktuserup/sp/p.m3u8" // *vidmod.VideoInfo 实现 M3u8Signable:应命中接口分支而非反射。 if _, ok := interface{}(v).(vidmod.M3u8Signable); !ok { t.Fatal("*vidmod.VideoInfo should implement vidmod.M3u8Signable") } signData(&ticketSigner{userID: 3, ip: "3.3.3.3", ua: "ua"}, v) for _, raw := range []string{v.SourceURL, v.H265Url, v.PreviewURL} { if !strings.HasPrefix(raw, "JHA-") { t.Fatalf("field not signed via interface: %q", raw) } } // previewURL 必须是试看票(Preview=true, Playable=false)。 _, tk, ok := VerifyPath("/"+v.PreviewURL, "3.3.3.3", "ua") if !ok || !tk.Preview || tk.Playable { t.Fatalf("previewURL should be a preview ticket: ok=%v tk=%+v", ok, tk) } }