B5 tests: theme store matrix — refusals, replace, chunks, icons, repo parsing, dot-prefixed packages

This commit is contained in:
Muse
2026-10-22 23:19:34 +00:00
parent 2558efd9fb
commit 000731c4e0
@@ -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)
}
}
}