Files
huangguo_server/app/service/m3u8ticket/ticket_test.go
T
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

280 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}