Files
Sakurasan e3ff0d3329 security: 会话可撤销与认证面加固
- users 加 token_version(schema v4):改密/登出递增,该用户全部已发 access/refresh 立即作废;refresh 改轮换式换发新 cookie
- 邀请码改精确匹配 + 注册成功后消费(一码一用),修复子串匹配让任意后缀放行、可无限薅体验金
- 生产校验拦下 .env.example 占位密钥;OT_ENV 枚举校验(拼错不再静默跳过断言);生产强制 COOKIE_SECURE;非 development 拒绝默认密码播种管理员
- WebAuthn 会话启用过期校验 + 4096 容量上限(未认证 login/begin 可无限撑爆内存),begin/complete 挂登录级限速
- /api/v1 挂 1MB 请求体上限;login 先限 IP 桶再解析 body;全局安全响应头 + CSP
- 连接池配置(PG 30/SQLite 8);SQLite DSN 自动追加 WAL + busy_timeout(驱动只认 _pragma,旧注释的 _journal_mode 写法不生效)
2026-10-29 02:56:46 +08:00

88 lines
3.0 KiB
Go

package config
import (
"strings"
"testing"
)
func prodCfg() *Config {
c := &Config{Env: "production"}
c.JWT.Secret = "a-real-random-secret-not-the-default"
c.JWT.CookieSecure = true
c.Master = "0123456789abcdef0123456789abcdef"
c.AdminPassword = "strong-enough-password"
return c
}
func TestValidateProduction(t *testing.T) {
if err := prodCfg().validate(); err != nil {
t.Fatalf("配齐密钥应通过:%v", err)
}
cases := []struct {
name string
mutate func(*Config)
want string
}{
{"默认 jwt secret", func(c *Config) { c.JWT.Secret = "dev-only-secret-change-me" }, "OT_JWT_SECRET"},
{"空 jwt secret", func(c *Config) { c.JWT.Secret = "" }, "OT_JWT_SECRET"},
// 复制 .env.example 不改密钥是最常见的部署路径,占位值必须拦下
{"示例文件里的 jwt secret 占位值", func(c *Config) { c.JWT.Secret = "change-me-to-a-long-random-string" }, "OT_JWT_SECRET"},
{"缺失 master key", func(c *Config) { c.Master = "" }, "OT_MASTER_KEY"},
{"过短 master key", func(c *Config) { c.Master = "short" }, "OT_MASTER_KEY"},
{"示例文件里的 master key 占位值", func(c *Config) { c.Master = "change-me-master-key" }, "OT_MASTER_KEY"},
{"默认管理员密码", func(c *Config) { c.AdminPassword = "admin123" }, "OT_ADMIN_PASSWORD"},
{"未设管理员密码", func(c *Config) { c.AdminPassword = "" }, "OT_ADMIN_PASSWORD"},
{"cookie 未开 secure", func(c *Config) { c.JWT.CookieSecure = false }, "OT_JWT_COOKIE_SECURE"},
}
for _, tc := range cases {
c := prodCfg()
tc.mutate(c)
err := c.validate()
if err == nil {
t.Fatalf("%s:应被拒绝", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s:报错应点名 %s,实际 %v", tc.name, tc.want, err)
}
}
// 多项同时缺失要一次全说清,别让人改一个试一个
c := prodCfg()
c.JWT.Secret = ""
c.Master = ""
err := c.validate()
if err == nil || !strings.Contains(err.Error(), "OT_JWT_SECRET") || !strings.Contains(err.Error(), "OT_MASTER_KEY") {
t.Fatalf("应一次列出全部问题:%v", err)
}
// 开发环境不拦(本地跑默认值就是设计如此)
dev := prodCfg()
dev.Env = "development"
dev.JWT.Secret = "dev-only-secret-change-me"
dev.JWT.CookieSecure = false
dev.Master = ""
dev.AdminPassword = "admin123"
if err := dev.validate(); err != nil {
t.Fatalf("development 不应拦截:%v", err)
}
// env 拼错(如 "prod")会静默跳过全部生产断言,必须枚举校验直接拒绝
if err := (&Config{Env: "prod"}).validate(); err == nil || !strings.Contains(err.Error(), "OT_ENV") {
t.Fatalf("未知 env 值应被拒绝:%v", err)
}
}
func TestSplitList(t *testing.T) {
if got := splitList(""); got != nil {
t.Fatalf("空串应为 nil,实际 %v", got)
}
if got := splitList(" , ,, "); got != nil {
t.Fatalf("纯分隔符应为 nil,实际 %v", got)
}
got := splitList("https://a.com, https://b.com ,,http://c.com")
if len(got) != 3 || got[0] != "https://a.com" || got[2] != "http://c.com" {
t.Fatalf("解析错误:%v", got)
}
}