Files
pika/internal/handler/web_handler_test.go
T
dushixiang 1af64eccd9 refactor: simplify theme system to filesystem-only with self-contained portal
主题系统从带数据库/事务/repo 层/远程下载/配置管理/主题市场的重型架构,
彻底简化为纯文件系统操作:

后端:
- 主题数据全部存放在 data/themes/ 目录,不经过数据库
- 删除主题配置功能(managed/raw/redirect schema、配置存取 API、
  ThemeConfiguration model、theme_configurations 表、repo 层)
- 删除远程下载(URL 安装、GitHub Release 更新、SSRF 防护、SHA-256)
- 删除版本范围检查(semver 解析/比较/匹配、pikaVersion 校验)
- 删除主题市场
- ThemeService 不再持有 db,合并为 theme.go + theme_util.go 两个文件
- 主题类型从 models 包移到 service 包(非数据库模型)
- 保留:ZIP 上传解压(含目录穿越防护)、目录扫描列表、启用/删除、
  覆盖安装、默认主题回退、启动恢复、入口 HTML 渲染缓存

前端 portal(官方默认主题):
- 从 47 个碎片文件精简到 15 个,按标准目录组织
- 删除 theme-sdk 包,API 客户端和类型内联回 portal,完全自包含
- 按职责拆分:types.ts / api.ts / constants.ts / lib/utils.ts /
  contexts/ColorMode.tsx / hooks/index.ts / components.tsx / pages/
- 每个页面一个文件,图表和子组件内联,fork 二开只需看一个文件
2026-08-09 22:45:43 +08:00

128 lines
4.4 KiB
Go

package handler
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/labstack/echo/v5"
)
func TestAdminPageRoutingBoundary(t *testing.T) {
for _, path := range []string{"/admin", "/admin/login", "/admin/github/callback", "/admin/oidc/callback", "/admin/agents"} {
if !isAdminPage(path) {
t.Errorf("expected admin route: %s", path)
}
}
for _, path := range []string{"/", "/login", "/github/callback", "/oidc/callback", "/servers/id", "/monitors/id", "/administrator", "/api/admin/themes"} {
if isAdminPage(path) {
t.Errorf("public/API route classified as admin page: %s", path)
}
}
}
func TestServeSPARejectsAPIWebSocketAssetsAndNonGET(t *testing.T) {
handler := &WebHandler{}
for _, test := range []struct {
method string
path string
}{
{http.MethodGet, "/api/unknown"},
{http.MethodGet, "/ws/unknown"},
{http.MethodPost, "/servers/1"},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(test.method, test.path, nil)
context := echo.New().NewContext(request, recorder)
err := handler.ServeSPA(context)
httpError, ok := err.(*echo.HTTPError)
if !ok || httpError.Code != http.StatusNotFound {
t.Errorf("%s %s should return 404, got %v", test.method, test.path, err)
}
}
}
func TestResolveStaticPathAndHeaders(t *testing.T) {
root := t.TempDir()
asset := filepath.Join(root, "assets", "app.js")
if err := os.MkdirAll(filepath.Dir(asset), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(asset, []byte("console.log('ok')"), 0o644); err != nil {
t.Fatal(err)
}
if resolved, ok := resolveStaticPath(root, "assets/app.js"); !ok || resolved == "" {
t.Fatal("valid static asset was rejected")
}
for _, path := range []string{"", "../secret", "assets\\app.js", "/../../secret"} {
if _, ok := resolveStaticPath(root, path); ok {
t.Errorf("unsafe path accepted: %q", path)
}
}
if runtime.GOOS != "windows" {
outside := filepath.Join(t.TempDir(), "secret.js")
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "link.js")); err != nil {
t.Fatal(err)
}
if _, ok := resolveStaticPath(root, "link.js"); ok {
t.Fatal("symlink asset was accepted")
}
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil)
context := echo.New().NewContext(request, recorder)
if err := serveStaticFile(context, root, "assets/app.js", true); err != nil {
t.Fatal(err)
}
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" ||
recorder.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" ||
recorder.Header().Get("Content-Type") != "text/javascript; charset=utf-8" {
t.Fatalf("unexpected static headers: %#v", recorder.Header())
}
}
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
webRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webRoot, "admin", "assets", "app.js"), []byte("admin"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("PIKA_WEB_DIR", webRoot)
e := echo.New()
handler := &WebHandler{}
e.GET("/admin/assets/*", handler.ServeAdminAsset)
e.GET("/*", handler.ServeSPA)
assetRecorder := httptest.NewRecorder()
e.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil))
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
t.Fatalf("wildcard asset route failed: status=%d body=%q", assetRecorder.Code, assetRecorder.Body.String())
}
adminRecorder := httptest.NewRecorder()
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
if adminRecorder.Code != http.StatusOK || adminRecorder.Body.String() != "<html>admin</html>" {
t.Fatalf("admin SPA route failed: status=%d body=%q", adminRecorder.Code, adminRecorder.Body.String())
}
apiRecorder := httptest.NewRecorder()
e.ServeHTTP(apiRecorder, httptest.NewRequest(http.MethodGet, "/api/unknown", nil))
if apiRecorder.Code != http.StatusNotFound || apiRecorder.Header().Get("Content-Type") == "text/html; charset=utf-8" {
t.Fatalf("unknown API entered SPA fallback: status=%d type=%q", apiRecorder.Code, apiRecorder.Header().Get("Content-Type"))
}
}