280 lines
9.7 KiB
Go
280 lines
9.7 KiB
Go
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 不实现 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)
|
||
}
|
||
}
|