B5 tests: theme store matrix — refusals, replace, chunks, icons, repo parsing, dot-prefixed packages
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
// Item 7 B5: theme store tests — the hub frontend.rs install/list/
|
||||
// remove matrices, against a scratch DB folder per test.
|
||||
|
||||
func setupThemeStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dir)
|
||||
if err := database.InitDB(filepath.Join(dir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
const auroraManifest = `{"name":"极光","short":"aurora","description":"","version":"1","author":"a","url":""}`
|
||||
|
||||
// packTheme builds a theme.tar.gz in memory from name→content files,
|
||||
// with an optional symlink entry (name→target).
|
||||
func packTheme(t *testing.T, files map[string][]byte, order []string, link *[2]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, name := range order {
|
||||
data := files[name]
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(data))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if link != nil {
|
||||
hdr := &tar.Header{Name: link[0], Linkname: link[1], Mode: 0o777, Typeflag: tar.TypeSymlink}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func refusalText(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal, got nil error")
|
||||
}
|
||||
var refusal *ProbeThemeRefusal
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("expected ProbeThemeRefusal, got %T: %v", err, err)
|
||||
}
|
||||
return refusal.Msg
|
||||
}
|
||||
|
||||
func TestInstallThemeHappyPathAndReplace(t *testing.T) {
|
||||
dir := setupThemeStore(t)
|
||||
files := map[string][]byte{
|
||||
"theme.json": []byte(auroraManifest),
|
||||
"dist/index.html": []byte("v1"),
|
||||
}
|
||||
theme, err := InstallProbeTheme(bytes.NewReader(packTheme(t, files, []string{"theme.json", "dist/index.html"}, nil)), "")
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if theme.Short != "aurora" {
|
||||
t.Fatalf("short = %q", theme.Short)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "themes", "aurora", "dist", "index.html"))
|
||||
if string(got) != "v1" {
|
||||
t.Fatalf("installed index = %q", got)
|
||||
}
|
||||
|
||||
// Reinstall replaces wholesale: files from v1 do not linger.
|
||||
files["dist/index.html"] = []byte("v2")
|
||||
files["dist/old.js"] = []byte("x")
|
||||
theme2 := packTheme(t, files, []string{"theme.json", "dist/index.html", "dist/old.js"}, nil)
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(theme2), ""); err != nil {
|
||||
t.Fatalf("reinstall: %v", err)
|
||||
}
|
||||
files["dist/index.html"] = []byte("v3")
|
||||
delete(files, "dist/old.js")
|
||||
theme3 := packTheme(t, files, []string{"theme.json", "dist/index.html"}, nil)
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(theme3), ""); err != nil {
|
||||
t.Fatalf("reinstall 2: %v", err)
|
||||
}
|
||||
got, _ = os.ReadFile(filepath.Join(dir, "themes", "aurora", "dist", "index.html"))
|
||||
if string(got) != "v3" {
|
||||
t.Fatalf("index after replace = %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "themes", "aurora", "dist", "old.js")); !os.IsNotExist(err) {
|
||||
t.Fatal("replaced theme left old.js behind")
|
||||
}
|
||||
// No staging/replaced litter in the themes root.
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "themes"))
|
||||
for _, e := range entries {
|
||||
if e.Name() != "aurora" {
|
||||
t.Fatalf("themes root litter: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallThemeDotPrefixedEntries pins the guboysky LuminaPlus
|
||||
// package shape: a leading "." directory entry and "./"-prefixed names
|
||||
// throughout (its tar was built with `tar -czf .`). The hub accepts the
|
||||
// package as-is; the store must too — the "." root entry creates
|
||||
// nothing, it is not a path escape.
|
||||
func TestInstallThemeDotPrefixedEntries(t *testing.T) {
|
||||
dir := setupThemeStore(t)
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
write := func(hdr *tar.Header, data []byte) {
|
||||
t.Helper()
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
write(&tar.Header{Name: ".", Mode: 0o755, Typeflag: tar.TypeDir}, nil)
|
||||
write(&tar.Header{Name: "./dist", Mode: 0o755, Typeflag: tar.TypeDir}, nil)
|
||||
manifest := []byte(auroraManifest)
|
||||
write(&tar.Header{Name: "./theme.json", Mode: 0o644, Size: int64(len(manifest))}, manifest)
|
||||
index := []byte("lumina-like")
|
||||
write(&tar.Header{Name: "./dist/index.html", Mode: 0o644, Size: int64(len(index))}, index)
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theme, err := InstallProbeTheme(bytes.NewReader(buf.Bytes()), "")
|
||||
if err != nil {
|
||||
t.Fatalf("install dot-prefixed package: %v", err)
|
||||
}
|
||||
if theme.Short != "aurora" {
|
||||
t.Fatalf("short = %q", theme.Short)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "themes", "aurora", "dist", "index.html"))
|
||||
if string(got) != "lumina-like" {
|
||||
t.Fatalf("installed index = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallThemeRefusals(t *testing.T) {
|
||||
setupThemeStore(t)
|
||||
good := map[string][]byte{
|
||||
"theme.json": []byte(auroraManifest),
|
||||
"dist/index.html": []byte("v1"),
|
||||
}
|
||||
goodPkg := packTheme(t, good, []string{"theme.json", "dist/index.html"}, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pkg []byte
|
||||
want string
|
||||
}{
|
||||
{"zip magic", []byte("PK\x03\x04rest"), themeNotGzip},
|
||||
{"plain tar", func() []byte {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
_ = tw.Close()
|
||||
return buf.Bytes()
|
||||
}(), themeNotGzip},
|
||||
{"truncated", goodPkg[:len(goodPkg)/2], themeDamaged},
|
||||
{"crc flipped", func() []byte {
|
||||
b := bytes.Clone(goodPkg)
|
||||
b[len(b)-8] ^= 1
|
||||
return b
|
||||
}(), themeDamaged},
|
||||
{"missing manifest", packTheme(t,
|
||||
map[string][]byte{"dist/index.html": []byte("v")},
|
||||
[]string{"dist/index.html"}, nil), "主题包里没有 theme.json"},
|
||||
{"source code layout", packTheme(t,
|
||||
map[string][]byte{"aurora-1/theme.json": []byte(auroraManifest), "aurora-1/dist/index.html": []byte("v")},
|
||||
[]string{"aurora-1/theme.json", "aurora-1/dist/index.html"}, nil), "Source code"},
|
||||
{"missing dist index", packTheme(t,
|
||||
map[string][]byte{"theme.json": []byte(auroraManifest)},
|
||||
[]string{"theme.json"}, nil), "主题包里没有 dist/index.html"},
|
||||
{"bad short", packTheme(t,
|
||||
map[string][]byte{
|
||||
"theme.json": []byte(`{"name":"x","short":"../evil","description":"","version":"1","author":"a","url":""}`),
|
||||
"dist/index.html": []byte("v"),
|
||||
}, []string{"theme.json", "dist/index.html"}, nil), "short 不能作为目录名"},
|
||||
{"broken manifest", packTheme(t,
|
||||
map[string][]byte{"theme.json": []byte("{"), "dist/index.html": []byte("v")},
|
||||
[]string{"theme.json", "dist/index.html"}, nil), "theme.json 格式不对"},
|
||||
{"symlink entry", packTheme(t, good,
|
||||
[]string{"theme.json", "dist/index.html"}, &[2]string{"dist/link", "/etc/passwd"}), "不支持的条目"},
|
||||
{"escape entry", packTheme(t,
|
||||
map[string][]byte{"theme.json": []byte(auroraManifest), "dist/index.html": []byte("v"), "../evil": []byte("x")},
|
||||
[]string{"theme.json", "dist/index.html", "../evil"}, nil), "路径越出了主题目录"},
|
||||
{"oversize file", packTheme(t,
|
||||
map[string][]byte{
|
||||
"theme.json": []byte(auroraManifest),
|
||||
"dist/index.html": []byte("v"),
|
||||
"dist/big.bin": make([]byte, maxThemeFile+1),
|
||||
}, []string{"theme.json", "dist/index.html", "dist/big.bin"}, nil), "超过单个文件"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := InstallProbeTheme(bytes.NewReader(tc.pkg), "")
|
||||
if got := refusalText(t, err); !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("refusal = %q, want substring %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Expect mismatch: an update may not install a different theme.
|
||||
_, err := InstallProbeTheme(bytes.NewReader(goodPkg), "nebula")
|
||||
if got := refusalText(t, err); !strings.Contains(got, "不是") {
|
||||
t.Fatalf("expect refusal = %q", got)
|
||||
}
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(goodPkg), "aurora"); err != nil {
|
||||
t.Fatalf("expect match install: %v", err)
|
||||
}
|
||||
|
||||
// A file at exactly the per-file ceiling installs.
|
||||
exact := map[string][]byte{
|
||||
"theme.json": []byte(auroraManifest),
|
||||
"dist/index.html": []byte("v"),
|
||||
"dist/exact.bin": make([]byte, maxThemeFile),
|
||||
}
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(
|
||||
packTheme(t, exact, []string{"theme.json", "dist/index.html", "dist/exact.bin"}, nil)), ""); err != nil {
|
||||
t.Fatalf("ceiling install: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeListSelectRemove(t *testing.T) {
|
||||
setupThemeStore(t)
|
||||
|
||||
// Nothing installed: the list is the embedded fallback alone,
|
||||
// selected by default, selectable.
|
||||
list, err := ProbeThemes()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 || !list[0].Builtin || list[0].Short != "default" || !list[0].Selected {
|
||||
t.Fatalf("builtin-only list = %+v", list)
|
||||
}
|
||||
if !ProbeThemeSelectable("") || !ProbeThemeSelectable("default") || ProbeThemeSelectable("aurora") {
|
||||
t.Fatal("selectable semantics wrong on empty store")
|
||||
}
|
||||
|
||||
// Install aurora + a disk copy of default.
|
||||
aurora := map[string][]byte{"theme.json": []byte(auroraManifest), "dist/index.html": []byte("a")}
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(
|
||||
packTheme(t, aurora, []string{"theme.json", "dist/index.html"}, nil)), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskDefault := map[string][]byte{
|
||||
"theme.json": []byte(`{"name":"默认主题","short":"default","description":"","version":"9","author":"a","url":""}`),
|
||||
"dist/index.html": []byte("d1"),
|
||||
"preview.png": []byte("DISK"),
|
||||
}
|
||||
if _, err := InstallProbeTheme(bytes.NewReader(
|
||||
packTheme(t, diskDefault, []string{"theme.json", "dist/index.html", "preview.png"}, nil)), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = ProbeThemes()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("list len = %d", len(list))
|
||||
}
|
||||
if list[0].Builtin || list[0].Version != "9" || !list[0].Preview {
|
||||
t.Fatalf("installed default must stand in for the builtin: %+v", list[0])
|
||||
}
|
||||
if got := ProbeThemePreview("default"); string(got) != "DISK" {
|
||||
t.Fatalf("disk preview = %q", got)
|
||||
}
|
||||
|
||||
// Switch + selection marking.
|
||||
if err := SetProbeCurrentTheme("aurora"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ProbeCurrentTheme() != "aurora" {
|
||||
t.Fatalf("current = %q", ProbeCurrentTheme())
|
||||
}
|
||||
list, _ = ProbeThemes()
|
||||
for _, theme := range list {
|
||||
if theme.Selected != (theme.Short == "aurora") {
|
||||
t.Fatalf("selected flags wrong: %+v", list)
|
||||
}
|
||||
}
|
||||
if err := SetProbeCurrentTheme("nope"); err == nil {
|
||||
t.Fatal("switch to missing theme must refuse")
|
||||
}
|
||||
|
||||
// Deleting the theme in use is permitted; selection falls back.
|
||||
if err := RemoveProbeTheme("aurora"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = ProbeThemes()
|
||||
for _, theme := range list {
|
||||
if theme.Short == "aurora" {
|
||||
t.Fatal("aurora still listed after delete")
|
||||
}
|
||||
if theme.Short == "default" && !theme.Selected {
|
||||
t.Fatal("selection must fall back to default")
|
||||
}
|
||||
}
|
||||
// Deleting the disk default leaves the embedded copy listed.
|
||||
if err := RemoveProbeTheme("default"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = ProbeThemes()
|
||||
if len(list) != 1 || !list[0].Builtin {
|
||||
t.Fatalf("after deleting disk default: %+v", list)
|
||||
}
|
||||
if got := ProbeThemePreview("default"); len(got) == 0 {
|
||||
t.Fatal("embedded preview must answer for the builtin")
|
||||
}
|
||||
if err := RemoveProbeTheme("aurora"); err == nil {
|
||||
t.Fatal("deleting a gone theme must refuse")
|
||||
}
|
||||
if err := RemoveProbeTheme("../etc"); err == nil {
|
||||
t.Fatal("path-y short must refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeChunkUpload(t *testing.T) {
|
||||
setupThemeStore(t)
|
||||
pkg := packTheme(t, map[string][]byte{
|
||||
"theme.json": []byte(auroraManifest),
|
||||
"dist/index.html": []byte("v1"),
|
||||
}, []string{"theme.json", "dist/index.html"}, nil)
|
||||
total := int64(len(pkg))
|
||||
half := total / 2
|
||||
|
||||
received, err := ReceiveProbeThemeChunk(0, total, bytes.NewReader(pkg[:half]))
|
||||
if err != nil || received != half {
|
||||
t.Fatalf("chunk 1: received=%d err=%v", received, err)
|
||||
}
|
||||
// A piece that does not continue where the last ended refuses.
|
||||
if _, err := ReceiveProbeThemeChunk(0+1, total, bytes.NewReader(pkg[1:])); err == nil ||
|
||||
!strings.Contains(refusalText(t, err), "分片接不上") {
|
||||
t.Fatalf("misaligned chunk: %v", err)
|
||||
}
|
||||
received, err = ReceiveProbeThemeChunk(half, total, bytes.NewReader(pkg[half:]))
|
||||
if err != nil || received != total {
|
||||
t.Fatalf("chunk 2: received=%d err=%v", received, err)
|
||||
}
|
||||
theme, err := FinishProbeThemeUpload()
|
||||
if err != nil {
|
||||
t.Fatalf("finish: %v", err)
|
||||
}
|
||||
if theme.Short != "aurora" {
|
||||
t.Fatalf("installed %q", theme.Short)
|
||||
}
|
||||
// Oversize total refuses up front.
|
||||
if _, err := ReceiveProbeThemeChunk(0, MaxThemeArchive+1, bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("oversize total must refuse")
|
||||
}
|
||||
// A fresh upload after completion starts from a truncated file.
|
||||
if _, err := ReceiveProbeThemeChunk(5, total, bytes.NewReader(pkg[5:])); err == nil ||
|
||||
!strings.Contains(refusalText(t, err), "不在了") {
|
||||
t.Fatalf("orphan continuation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeSiteIcon(t *testing.T) {
|
||||
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n...."))
|
||||
mime, data, err := ProbeSiteIcon("data:image/png;base64," + png)
|
||||
if err != nil || mime != "image/png" || len(data) == 0 {
|
||||
t.Fatalf("png icon: %s %v %v", mime, data, err)
|
||||
}
|
||||
// The declared type is not trusted: SVG bytes declared as png.
|
||||
svg := base64.StdEncoding.EncodeToString([]byte("<svg xmlns='http://www.w3.org/2000/svg'/>"))
|
||||
mime, _, err = ProbeSiteIcon("data:image/png;base64," + svg)
|
||||
if err != nil || mime != "image/svg+xml" {
|
||||
t.Fatalf("svg icon: %s %v", mime, err)
|
||||
}
|
||||
if _, _, err := ProbeSiteIcon("data:text/html;base64,PHN2Zz4="); err == nil {
|
||||
t.Fatal("non-image prefix must refuse")
|
||||
}
|
||||
big := base64.StdEncoding.EncodeToString(make([]byte, maxSiteIcon+1))
|
||||
if _, _, err := ProbeSiteIcon("data:image/png;base64," + big); err == nil ||
|
||||
!strings.Contains(refusalText(t, err), "20 KiB") {
|
||||
t.Fatalf("oversize icon: %v", err)
|
||||
}
|
||||
if _, _, err := ProbeSiteIcon("data:image/png;base64,not base64!"); err == nil {
|
||||
t.Fatal("bad base64 must refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeGitHubRepoParsing(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
ownerRepo string
|
||||
ok bool
|
||||
}{
|
||||
{"https://github.com/monitor-probe/monitor-theme-default", "monitor-probe/monitor-theme-default", true},
|
||||
{"https://github.com/a/b/tree/main", "a/b", true},
|
||||
{"https://github.com/a/b.git", "a/b", true},
|
||||
{"https://github.com/a/b?tab=readme#x", "a/b", true},
|
||||
{"https://github.com/a", "", false},
|
||||
{"https://github.com/a/", "", false},
|
||||
{"https://example.com/a/b", "", false},
|
||||
{"https://github.com/../b", "", false},
|
||||
{"https://github.com/a/b c", "", false},
|
||||
{"http://github.com/a/b", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
owner, repo, ok := probeGitHubRepo(tc.url)
|
||||
if ok != tc.ok || (ok && owner+"/"+repo != tc.ownerRepo) {
|
||||
t.Errorf("github_repo(%q) = %q/%q/%v", tc.url, owner, repo, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user