Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
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-<appid>/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 不实现 M3u8SignablesignData 会落到反射兜底 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)
}
}