* feat(web): 搭建新旧前端独立架构与界面切换 * feat(web): 设计珊瑚主题新版工作区框架 * refactor(web): 精调新版框架并移除预览占位内容 * fix(web): 隔离新版页面路由并恢复经典版启动 * fix(web): 修复页头菜单定位并精简导航提示 * feat(web): 优化导航布局并接入侧栏版本检查 * refactor(web): 精调侧栏底部外链与版本展示 * refactor(web): 整理侧栏外链并增加赞助入口 * refactor(web): 调整路由检查菜单与版本栏对齐 * Codex worktree snapshot: archive-cleanup Co-authored-by: Codex * fix(web): 恢复新版浮层组件样式 * feat(web): 完成新版登录认证与会话管理 * feat(web): 增加登录记忆选项并精简登录页 * fix(web): 独立展示访问密钥身份标识 * fix(modern): 调整快速导航触发器高度 * refactor(modern): 统一页面标题与设计令牌规范 * feat(modern): 完善分组工作区与公共框架 * feat(modern): 完善分组列表与公共组件 * feat(modern): 完成 API 密钥分组创建与模型批量选择 * feat(modern): 完善订阅分组创建与并行账号接入 * feat(modern): 完善分组详情与凭据管理 * feat(modern): 完善凭据卡片与高级配置交互 * feat(modern): 完善分组筛选并校正凭据统计 * feat(modern): 完善分组操作并统一消息与路由状态 * fix(modern): 统一凭据卡片底栏文字对齐 * feat(modern): 完善访问密钥管理与公共表单组件 * fix(modern): 区分页面与局部操作的加载反馈 * feat(modern): 完善请求日志页面与筛选展示 * refactor(modern): 精简日志表格字段与排版并降低渲染开销 表格只有单一字号、两档色阶,且每个配对单元格重复渲染表头已有的标签。 建立五档字号与三档色阶,数字列改等宽右对齐,删除格内标签。 字段配对从 7 组扩到 17 组并覆盖全部字段,避免非默认列各占一列; 细分缓存写入与两个完整性状态移出表格,仅保留在详情面板; 思考模式、强度、预算合并为一列,按强度、预算、开关的优先级取一, 等级保留上游原值不作翻译。列宽按内容与表头重排,全展开总宽减少约 480px。 表头与内容原先分属两个滚动容器、靠 JS 同步 scrollLeft,横向滚动有延迟, 改为同一容器内 sticky;行右内边距归零,固定列不再随滚动露出缝隙。 渲染上,AppOverflowText 仅在文字溢出时才创建 AppTooltip, 溢出检测共用一个 ResizeObserver,AppTooltip 的作用域属性改为缓存计算。 * refactor(modern): 重排日志高级筛选并精简筛选标签 * feat(modern): 统一协议标签并优化日志详情 * feat(modern): 完善请求日志展示与脱敏复制 * perf(modern): 减少页面切换等待与重复渲染开销 * fix(modern): 精简分页提示并缩小日期弹层 * feat(modern): 完善用量统计页面 * feat(modern): 支持日志字段快捷筛选 * feat(modern): 完善健康状态页面 * fix(health): 返回全部异常账号 * refactor(modern): 精简健康页并统一监控交互 * fix(brand): 统一品牌图形并修复刷新位置跳动 * feat(modern): 实现模型管理页面 * feat(modern): 完善全局设置页面 * fix(modern): 统一原生协议展示与弹窗确认焦点 * fix(modern): 优化全局滚动条体验 * refactor(modern): 模型卡片改为来源价格表格式并支持分组跳转 - 来源行改为与表头共用同一套列定义(来源/分组/4 项价格/计价方式/操作), 多来源时价格上下对齐可比 - 协议折成一行中性摘要,悬浮查看完整列表,不再占用卡片最强的颜色 - 分组徽章对管理员改为可点击跳转到分组详情,非管理员保持纯展示 - 计价方式按常见程度分级:自动匹配为默认灰字,待计价才用警示徽章 - 卡片列表最小宽度从 320px 提到 620px 以容纳价格表;表格内容超宽时整体横向滚动 * fix(modern): 修正模型卡片价格溢出、行高不齐与列错位 - 价格列宽改为按本卡最长价格计算的共享变量:原先写死 52px,9 位小数的 单价会溢出压到隔壁列;用 max-content 则表头与来源行各算各的导致列错位 - 来源单元格由 flex-wrap 改为网格:长渠道名原先会把图标、名称、档位各自 甩到新行,单元格高度从 24px 涨到 87px - 分组只展开第一个并禁止换行,超出交给 +N 提示,行高不再被撑乱 - 行内容压进 40px 下限,有无上游模型的行等高 - 溢出时行宽跟随内容,底色与分隔线不再在容器右边缘断掉 - 分组标签常态加描边,亮色下 subtle 几乎等于卡片白底、看不出可点 * feat(modern): 模型卡改多列排布并重做卡头视觉 - 网格改为 CSS 多列:来源数不同导致卡高不一,网格按行内最高的卡留空洞, 实测同一批数据容器高从 616px 降到 576px 且无空洞 - 卡头加冷色渐变,自右上向左下淡出,左侧留白给模型名 - 新增 --modern-model-card-tint,沿用账号卡那套 info 蓝调,避开主色的橙 - 模型名靠左独占一端,协议/来源/分组三项统计右对齐 * refactor(modern): 精简模型详情侧边栏 - 去掉「全部来源」中间页:多来源靠顶部下拉切换即可,进面板直接落到 第一个来源,随之移除返回箭头与只服务于该页的 ModelSourcePreview - 面板宽度 sheet(660px) 改回 default(540px),与日志详情、访问密钥面板一致 - 价格由每档重复四个标签的块改为表格:表头只写一次,标准档与各阶梯档、 各模式上下对齐可比,极端价格不再撑破容器 - 模型资料标签列定宽右对齐,原先 auto 会让每行按各自内容算宽而行间错位 - 关联分组去掉 AppButton>RouterLink>AppBadge 三层嵌套,改用与模型卡共用的 可点标签样式(新增 model-group-chip.css) - 清理随视图作废的样式与导入 * fix(modern): 换掉卡头底色并重排侧边栏价格表 - 卡头 tint 换成冷灰蓝 #e4ebf5/#2c3340,渐变两端都保留色:原先淡出到 纯白,左下角与内容底色无差,所以区分不出卡头 - 修正上一轮未生效的改动:tint 仍混着 8% accent-soft(橙),当时单行 replace 没匹配上已被格式化成多行的定义,且漏了断言 - 价格表单位说明移入表头首列,消掉标签列 225px 空白(占表宽 44%) - 去掉价格表外框与表头底色,540px 侧栏里原先过重 - 价格四列收紧靠右(各 64px),数字挨在一起才便于比较 - 计价方式徽章移到「计价规则」分段标题行 * fix(modern): 卡头改用右上角径向晕染 照搬用量统计顶部卡片的手法:radial-gradient(ellipse at top right) 配低比例 混色,基色取成功绿。原先用线性渐变方向一直不对——to bottom left 会按盒子 比例换算,卡头宽高比约 15:1 时算出来接近 86°,等于水平色带;即便改成固定 225deg,扁盒子里的线性渐变也只是一条生硬的斜带,做不出角落发散的效果。 * feat(modern): 模型卡卡头晕染定为蓝调 基色改用 --modern-chart-input,与用量统计里 Token 卡同一支蓝,两页同源。 * fix(modern): 阶梯价入口仅保留标准档,Fast/Ultrafast 不再翻译 - Fast、Ultrafast 是计价档位专名(对应 OpenAI service_tier),三语一律不译 - 阶梯价的新增入口收窄到标准档:后端 NormalizeModePriceSchedules 直接驳回 Fast 带阶梯,此前编辑器对所有档位都给了按钮,加完保存必然失败; Ultrafast 后端虽未拦截,按约定前端统一不做 - 非标准档改为提示「仅标准档支持阶梯价」,已存在的阶梯仍可见可删, 避免静默隐藏却在保存时原样回传 * fix(modern): Fast 与 Ultrafast 档位行为对齐 后端对两档一视同仁:validPricingModeKey 只排除 standard,没有模型级限制; 计费同走 quote.go 查 ModeSchedules,仅触发参数不同(service_tier 取 fast/priority 与 ultrafast)。此前前端只给 Ultrafast 添加入口、删除按钮也 只对 Ultrafast 开放,目录同步来的 Fast 档因此无法删除。 - 添加入口改为按 optionalModes 渲染,两档对称 - 删除按钮放开到所有非标准档 - 阶梯价仍只在标准档提供(Fast 后端驳回,Ultrafast 按约定统一不做) * fix(modern): 修正价格表头下方的边框重叠 选择器 .row + .row:not(--head) 把紧跟表头的第一个数据行也匹配进去了, 它的上边框与表头的下边框落在同一像素,叠出一条明显更粗的线。改为要求 前一项也是数据行。 * fix(frontend): 限制经典版入口为管理员 * refactor(modern): 精调全局设置页排版 - 状态徽章只在有状态时出现:17 个里 14 个是「默认」,等于反复声明 「这里没什么特别的」,现仅剩已配置/已修改/环境锁定 3 个 - 设置项控件列由 auto 改为定宽,右边缘从 4 种位置收敛到 1 种 - 表单限宽 760px:内容区近 1000px,撑满会把标签与控件拉得过开 - 分区之间加细线隔断,功能边界不再只靠留白区分 - 子分组标题原先比它统领的设置项标签还小还淡,层级倒置;改用组标签 质感(caption + 字距 + 延伸线) - 移除「显示模式 / 界面语言」:右上角已有同样入口,此处重复 - 新版界面预览改用概念图:原先是 24KB 真实截图,与经典版的抽象示意 图风格割裂;仓库里原有的 modern.svg 实为带虚线框的加号占位图, 已按 classic.svg 的色板与抽象度重画为「左侧栏 + 卡片」 - 系统信息标签列定宽右对齐:原先 dt 用 flex:none 按各自文字宽度收缩, 「数据库」与「管理员密钥来源」差近一倍,取值起点全部错开; 三种更新状态也合并为一行 * refactor(modern): 梳理设置页布局与交互细节 * feat(modern): 完善吉祥物彩蛋与版本更新提示 * feat(modern): 补全总览与路由检查页面 * refactor(modern): 精简首页并整合路由检查 * feat(modern): 重做首页为接入优先布局 首页原本只展示分组、模型、凭据、密钥四个库存计数,这些数字一周不变, 且每个都对应侧边栏的一个导航项。真正回答「现在跑得怎么样」的 /api/home/statistics 接口后端早已实现,经典版在用,新版一直没接。 - 顶部换成一行状态条:服务状态 + 库存事实 + 版本与运行时长 - 接入客户端重做成左目录 + 按客户端类型分化的配置输出: CLI 客户端给可一次粘贴执行的终端命令(Codex 的 toml 改写成 heredoc), 图形客户端给设置界面示意图与编号字段对照,可深链的把一键导入置顶 - 修掉 Cline 的缺口:它需要模型名但输出形态返回空块, 界面上选了模型却没有任何地方显示,现在模型名是字段表的一行 - 协议不匹配改为选择前就在目录里禁用,提示里点名具体协议 - 路由检查折叠成工具条,不再以完整工作台占据首页 - 右栏依次为订阅账号、需要处理、24 小时请求趋势、访问密钥; 需要处理由已有的分组数据推出,不额外请求健康接口, 额度预警不重复(订阅账号已按账号画了额度条) * refactor(modern): 精调首页接入面板与侧栏排版 上一版实现丢了设计稿里的若干排版细节,逐条补回: - 客户端目录恢复协议标签。协议枚举全称有 16 个字符放不下, 给 AppProtocolTag 加 short 模式取短名(Responses / Completions), 颜色仍由同一处定义,不另写一套 - 界面示意图补上窗口标题栏文字,此前只画了三个点 - 没登记字段原文的客户端补两行无标签占位,示意图要读起来像一个设置表单, 而不是「这个界面只有三个框」;不替没核实过的客户端编字段名 - 补上「把右边的值按编号填到左图对应的位置」的引导语 - 一键导入横幅不再复用确认对话框的文案(那句带问号),改用常驻横幅的说法 - 整段配置块的标题从裸的 JSON 改成「也可以直接复制完整配置」 - 状态条加入待办入口,与右栏面板共用 collectAttention 的同一份计数 - 订阅账号卡去掉「更新于」(页头已有整页刷新时间)、重置券并进页脚, 并限制为前 3 个:账号多时这块会把下面三块全顶到折叠线以下 * fix(modern): 修正首页待办取材与侧栏面板的溢出 - 需要处理改读 /api/health。此前只按分组凭据计数判定,推不出额度将尽、 充值卡临期、访问密钥被费用额度挡住这几类,健康实例上永远是空的, 看起来就像坏了。现在覆盖无可用凭据、凭据被拉黑、额度将尽、 充值卡临期、密钥受限五类,状态条计数与面板读同一份结果 - 订阅账号按设计改成一个账号一条进度条,取剩余最少的那个窗口; 额度宽裕时不写重置时刻,可用是常态不出徽章 - 访问密钥不再溢出:grid 与 flex 子项的 min-width:auto 会让长密钥名 把徽章顶出卡片,链路上每层都要显式解开;徽章与结论数字不参与压缩 - 补回 HomeAccounts 漏掉的 AppPanel 导入。Vue 对未注册组件不报错, 整块面板被渲染成 <apppanel> 字面标签,标题与边框全部丢失 - 「还有 N 项」不再套用条目行的三列栅格,此前文字被挤进 14px 的 图标列,一个字一行 - 待办条目以分组名优先收缩,说明文字先被截断;说明文字同时收短 - 接入面板去掉无用的副标题 * refactor(modern): 精调首页布局与接入交互 * fix(modern): 收紧首页布局并移除客户端协议限制 * fix(modern): 优化首页接入对齐与侧栏密度 * fix(modern): 记住客户端接入选择并修正订阅额度展示 * fix(modern): 统一客户端接入输入尺寸并精简说明 * fix(modern): 优化分组排序与订阅账号周期及并行同步 * fix(modern): 优化订阅额度周期与同步反馈 * fix(modern): 完善首页健康提醒与独立路由检查 * feat(modern): 增加额度窗口满额预估 * refactor(modern): decouple shared frontend utilities * docs(readme): refresh localized screenshots * fix(web): harden startup fallback and Group ID parsing
416 lines
15 KiB
Go
416 lines
15 KiB
Go
package webui
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/platform/httproute"
|
|
)
|
|
|
|
func TestServerServesSameIndexForExplicitPageRoutes(t *testing.T) {
|
|
const wantCSP = "default-src 'self'; script-src 'self'; style-src 'self'; " +
|
|
"style-src-elem 'self' " +
|
|
"'sha256-60LHlRjW/B3CtzIoE/Lf1/NEDvko9efWMFaGVhHu/cs=' " +
|
|
"'sha256-0sLsI2a+NIcumVvBF9zD/ArGqlZR2xfnxsALPmK7nj8='; " +
|
|
"style-src-attr 'unsafe-inline'; " +
|
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " +
|
|
"base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
|
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>test index</title>")},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
var firstBody string
|
|
for _, target := range []string{
|
|
"/", "/login", "/import", "/groups/42", "/access-keys", "/monitor?tab=logs", "/models", "/settings",
|
|
"/monitor/usage", "/monitor/logs", "/monitor/health", "/monitor/inspector",
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("GET %s status = %d, want 200", target, recorder.Code)
|
|
}
|
|
if firstBody == "" {
|
|
firstBody = recorder.Body.String()
|
|
}
|
|
if recorder.Body.String() != firstBody {
|
|
t.Fatalf("GET %s body differs from index", target)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("GET %s Cache-Control = %q, want no-cache", target, got)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
|
|
t.Fatalf("GET %s Content-Type = %q, want HTML", target, got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("GET %s X-Content-Type-Options = %q", target, got)
|
|
}
|
|
if got := recorder.Header().Get("X-Frame-Options"); got != "DENY" {
|
|
t.Fatalf("GET %s X-Frame-Options = %q", target, got)
|
|
}
|
|
got := recorder.Header().Get("Content-Security-Policy")
|
|
if strings.Contains(got, "style-src 'self' 'unsafe-inline'") {
|
|
t.Fatalf("GET %s CSP allows broad inline styles: %q", target, got)
|
|
}
|
|
if got != wantCSP {
|
|
t.Fatalf("GET %s CSP = %q, want %q", target, got, wantCSP)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerCSPAllowsDropdownViewportStyles(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>test index</title>")},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
testEngine(server).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
|
|
|
var styleSources string
|
|
for _, directive := range strings.Split(recorder.Header().Get("Content-Security-Policy"), ";") {
|
|
if strings.HasPrefix(strings.TrimSpace(directive), "style-src-elem ") {
|
|
styleSources = directive
|
|
}
|
|
}
|
|
// Reka UI 2.10.4 实际注入的文本,首尾空格也参与 CSP 哈希计算。
|
|
for _, component := range []string{"select", "combobox"} {
|
|
t.Run(component, func(t *testing.T) {
|
|
selector := "[data-reka-" + component + "-viewport]"
|
|
css := " /* Hide scrollbars cross-browser and enable momentum scroll for touch devices */ " +
|
|
selector + " { scrollbar-width:none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch; } " +
|
|
selector + "::-webkit-scrollbar { display: none; } "
|
|
hash := sha256.Sum256([]byte(css))
|
|
source := "'sha256-" + base64.StdEncoding.EncodeToString(hash[:]) + "'"
|
|
if !strings.Contains(styleSources, source) {
|
|
t.Fatalf("CSP blocks %s viewport style: missing %s in %q", component, source, styleSources)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerServesModelsDeepLinkWithoutCatchingUnknownNestedPaths(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>models</title>")},
|
|
}, "dist"))
|
|
|
|
models := httptest.NewRecorder()
|
|
engine.ServeHTTP(models, httptest.NewRequest(http.MethodGet, "/models", nil))
|
|
if models.Code != http.StatusOK || !strings.Contains(models.Body.String(), "<title>models</title>") {
|
|
t.Fatalf("GET /models = %d %q, want embedded index", models.Code, models.Body.String())
|
|
}
|
|
|
|
unknown := httptest.NewRecorder()
|
|
engine.ServeHTTP(
|
|
unknown,
|
|
httptest.NewRequest(http.MethodGet, "/models/unknown", nil),
|
|
)
|
|
if unknown.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown nested models path = %d, want 404", unknown.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerServesGroupsCollectionWithoutCatchingUnknownNestedPaths(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>groups</title>")},
|
|
}, "dist"))
|
|
|
|
groups := httptest.NewRecorder()
|
|
engine.ServeHTTP(groups, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
|
if groups.Code != http.StatusOK || !strings.Contains(groups.Body.String(), "<title>groups</title>") {
|
|
t.Fatalf("GET /groups = %d %q, want embedded index", groups.Code, groups.Body.String())
|
|
}
|
|
|
|
unknown := httptest.NewRecorder()
|
|
engine.ServeHTTP(
|
|
unknown,
|
|
httptest.NewRequest(http.MethodGet, "/groups/unknown/path", nil),
|
|
)
|
|
if unknown.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown nested groups path = %d, want 404", unknown.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerFallbackReturnsNotFoundForUnknownRequests(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>fallback</title>")},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
browserPage := httptest.NewRecorder()
|
|
browserPageRequest := httptest.NewRequest(http.MethodGet, "/phase-2-unknown-route", nil)
|
|
browserPageRequest.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9")
|
|
engine.ServeHTTP(browserPage, browserPageRequest)
|
|
if browserPage.Code != http.StatusNotFound ||
|
|
!strings.Contains(browserPage.Body.String(), "<title>fallback</title>") {
|
|
t.Fatalf(
|
|
"unknown browser page = %d %q, want 404 SPA index",
|
|
browserPage.Code,
|
|
browserPage.Body.String(),
|
|
)
|
|
}
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
accept string
|
|
}{
|
|
{name: "non HTML client", method: http.MethodGet, target: "/unknown"},
|
|
{
|
|
name: "HTML explicitly unacceptable",
|
|
method: http.MethodGet,
|
|
target: "/unknown",
|
|
accept: "text/html;q=0, application/json",
|
|
},
|
|
{name: "non GET browser request", method: http.MethodPost, target: "/unknown", accept: "text/html"},
|
|
{name: "control namespace", method: http.MethodGet, target: "/api/unknown", accept: "text/html"},
|
|
{name: "OpenAI namespace", method: http.MethodGet, target: "/v1/unknown", accept: "text/html"},
|
|
{name: "Gemini namespace", method: http.MethodGet, target: "/v1beta/unknown", accept: "text/html"},
|
|
{name: "health namespace", method: http.MethodGet, target: "/health/unknown", accept: "text/html"},
|
|
{
|
|
name: "Chrome DevTools workspace probe",
|
|
method: http.MethodGet,
|
|
target: "/.well-known/appspecific/com.chrome.devtools.json",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(testCase.method, testCase.target, nil)
|
|
if testCase.accept != "" {
|
|
request.Header.Set("Accept", testCase.accept)
|
|
}
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf(
|
|
"%s %s = %d %q, want 404",
|
|
testCase.method,
|
|
testCase.target,
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerUsesCompileFallbackWhenIndexIsMissing(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/assets/embed-placeholder.txt": &fstest.MapFile{Data: []byte("marker")},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "前端资源尚未构建") {
|
|
t.Fatalf("fallback response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServerServesAssetsWithImmutableCaching(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/assets/app.js": &fstest.MapFile{Data: []byte("export default true")},
|
|
"dist/assets/app.css": &fstest.MapFile{Data: []byte("body { color: black; }")},
|
|
"dist/assets/app": &fstest.MapFile{Data: []byte{0x00, 0x01}},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
target string
|
|
wantMediaTypes []string
|
|
}{
|
|
{name: "javascript", target: "/assets/app.js", wantMediaTypes: []string{"text/javascript", "application/javascript"}},
|
|
{name: "stylesheet", target: "/assets/app.css", wantMediaTypes: []string{"text/css"}},
|
|
{name: "no extension", target: "/assets/app", wantMediaTypes: []string{"application/octet-stream"}},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, testCase.target, nil))
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("asset status = %d, want 200", recorder.Code)
|
|
}
|
|
contentType := recorder.Header().Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
t.Fatalf("parse Content-Type %q: %v", contentType, err)
|
|
}
|
|
matches := false
|
|
for _, want := range testCase.wantMediaTypes {
|
|
if mediaType == want {
|
|
matches = true
|
|
break
|
|
}
|
|
}
|
|
if !matches {
|
|
t.Fatalf("Content-Type media type = %q, want one of %v", mediaType, testCase.wantMediaTypes)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
|
|
t.Fatalf("Cache-Control = %q", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("X-Content-Type-Options = %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerServesThemeBootstrapAsExplicitRootAsset(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/theme-bootstrap.js": &fstest.MapFile{Data: []byte("document.documentElement.dataset.theme = 'dark'")},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/theme-bootstrap.js", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("theme bootstrap status = %d, want 200", recorder.Code)
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(recorder.Header().Get("Content-Type"))
|
|
if err != nil {
|
|
t.Fatalf("parse theme bootstrap Content-Type: %v", err)
|
|
}
|
|
if mediaType != "text/javascript" && mediaType != "application/javascript" {
|
|
t.Fatalf("theme bootstrap Content-Type = %q, want JavaScript", mediaType)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("theme bootstrap Cache-Control = %q, want no-cache", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("theme bootstrap X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "dataset.theme") {
|
|
t.Fatalf("theme bootstrap body = %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServerServesFaviconAsExplicitRootAsset(t *testing.T) {
|
|
want := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h1v1z"/></svg>`)
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/favicon.svg": &fstest.MapFile{Data: want},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/favicon.svg", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("favicon status = %d, want 200", recorder.Code)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); got != "image/svg+xml" {
|
|
t.Fatalf("favicon Content-Type = %q, want image/svg+xml", got)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("favicon Cache-Control = %q, want no-cache", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("favicon X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if !bytes.Equal(recorder.Body.Bytes(), want) {
|
|
t.Fatalf("favicon body = %q, want %q", recorder.Body.Bytes(), want)
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotServeLegacyFaviconAlias(t *testing.T) {
|
|
legacy := []byte{0x00, 0x00, 0x01, 0x00}
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/favicon.ico": &fstest.MapFile{Data: legacy},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/favicon.ico", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("legacy favicon status = %d, want 404", recorder.Code)
|
|
}
|
|
if bytes.Equal(recorder.Body.Bytes(), legacy) {
|
|
t.Fatalf("legacy favicon body = %v, want no ICO alias", recorder.Body.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotExposeFilesOutsideAssets(t *testing.T) {
|
|
const (
|
|
indexSecret = "outer-index-secret"
|
|
fileSecret = "outer-file-secret"
|
|
)
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>" + indexSecret)},
|
|
"dist/secret": &fstest.MapFile{Data: []byte(fileSecret)},
|
|
}, "dist"))
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
target string
|
|
}{
|
|
{name: "parent traversal", target: "/assets/../index.html"},
|
|
{name: "encoded parent traversal", target: "/assets/%2e%2e/secret"},
|
|
{name: "double slash absolute shape", target: "/assets//secret"},
|
|
{name: "encoded absolute shape", target: "/assets/%2Fsecret"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, testCase.target, nil))
|
|
|
|
if recorder.Code == http.StatusOK {
|
|
t.Fatalf("GET %s status = 200, want rejection or redirect", testCase.target)
|
|
}
|
|
if body := recorder.Body.String(); strings.Contains(body, indexSecret) || strings.Contains(body, fileSecret) {
|
|
t.Fatalf("GET %s leaked content outside assets: %q", testCase.target, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotHandleBackendOrUnknownRoutes(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
}, "dist"))
|
|
|
|
for _, target := range []string{
|
|
"/api/unknown", "/v1/models", "/unknown", "/settings/unknown", "/assets/missing.js",
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("GET %s status = %d, want 404", target, recorder.Code)
|
|
}
|
|
if strings.Contains(strings.ToLower(recorder.Body.String()), "<!doctype html") {
|
|
t.Fatalf("GET %s unexpectedly returned the UI index", target)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testEngine(server *Server) *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
engine.RedirectTrailingSlash = false
|
|
registry, err := httproute.NewRegistry(server.HTTPModule())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := registry.Bind(engine); err != nil {
|
|
panic(err)
|
|
}
|
|
return engine
|
|
}
|