Files
usernode/internal/config/config_test.go
T
cao.wangrenbo 630d240dc0 feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证:
- 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖)
- 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速
- 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志)
- cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件

用户管理(admin):
- CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd
- system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单)
- Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板
- 关键操作接入 append-only 审计

其他:
- CLI user otp 改 DB store,与邮件通道真正对齐
- 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1
- 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
2026-08-29 23:40:20 +08:00

124 lines
3.4 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func writeTemp(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadDefault(t *testing.T) {
cfg, err := LoadDefault()
if err != nil {
t.Fatalf("LoadDefault: %v", err)
}
if cfg.Database.Driver != "sqlite" {
t.Errorf("default driver = %q, want sqlite", cfg.Database.Driver)
}
if cfg.Policy.DefaultTTL != 90*24*time.Hour {
t.Errorf("default ttl = %v", cfg.Policy.DefaultTTL)
}
if cfg.System.UserPrefix != "ext_" {
t.Errorf("default user_prefix = %q", cfg.System.UserPrefix)
}
if cfg.Auth.MaxLoginFailures != 5 || cfg.Auth.LockDuration != 15*time.Minute {
t.Errorf("default auth = %+v", cfg.Auth)
}
if !cfg.System.DryRun {
t.Error("default dry_run should be true (安全默认)")
}
}
func TestLoadFileOverrides(t *testing.T) {
path := writeTemp(t, `
[server]
listen = "0.0.0.0:9999"
session_ttl = "2h"
[policy]
default_ttl = "720h"
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != "0.0.0.0:9999" {
t.Errorf("listen = %q", cfg.Server.Listen)
}
if cfg.Server.SessionTTL != 2*time.Hour {
t.Errorf("session_ttl = %v", cfg.Server.SessionTTL)
}
if cfg.Policy.DefaultTTL != 30*24*time.Hour {
t.Errorf("default_ttl = %v", cfg.Policy.DefaultTTL)
}
}
func TestEnvOverrides(t *testing.T) {
t.Setenv("USERNODE_DATABASE_DRIVER", "mysql")
t.Setenv("USERNODE_DATABASE_DSN", "u:p@tcp(h:3306)/db")
t.Setenv("USERNODE_POLICY_OTPTTL", "5m")
t.Setenv("USERNODE_SYSTEM_SUDO", "true")
t.Setenv("USERNODE_SYSTEM_DRY_RUN", "false")
t.Setenv("USERNODE_SERVER_TRUSTED_PROXIES", "10.0.0.1, 10.0.0.2")
t.Setenv("USERNODE_AUTH_MAX_LOGIN_FAILURES", "3")
t.Setenv("USERNODE_AUTH_LOCK_DURATION", "5m")
t.Setenv("USERNODE_APP_BASE_URL", "https://un.example.com")
cfg, err := LoadDefault()
if err != nil {
t.Fatalf("LoadDefault: %v", err)
}
if cfg.Database.Driver != "mysql" || cfg.Database.DSN != "u:p@tcp(h:3306)/db" {
t.Errorf("database = %+v", cfg.Database)
}
if cfg.Policy.OTPTTL != 5*time.Minute {
t.Errorf("otp_ttl = %v", cfg.Policy.OTPTTL)
}
if !cfg.System.Sudo {
t.Error("system.sudo should be true")
}
if cfg.Auth.MaxLoginFailures != 3 || cfg.Auth.LockDuration != 5*time.Minute {
t.Errorf("auth = %+v", cfg.Auth)
}
if cfg.App.BaseURL != "https://un.example.com" {
t.Errorf("base_url = %q", cfg.App.BaseURL)
}
if len(cfg.Server.TrustedProxies) != 2 || cfg.Server.TrustedProxies[0] != "10.0.0.1" {
t.Errorf("trusted_proxies = %v", cfg.Server.TrustedProxies)
}
}
func TestInvalidDriver(t *testing.T) {
path := writeTemp(t, "[database]\ndriver = \"oracle\"\ndsn = \"x\"\n")
if _, err := Load(path); err == nil {
t.Fatal("expected error for unsupported driver")
}
}
func TestCamelToSnake(t *testing.T) {
cases := map[string]string{
"SessionTTL": "session_ttl",
"Listen": "listen",
"OTPTTL": "otpttl", // 全大写缩写按一个词处理(与 strcase 行为一致)
"OTPCooldown": "otp_cooldown",
"TrustedProxies": "trusted_proxies",
"AuthorizedKeysDir": "authorized_keys_dir",
"HomeBase": "home_base",
}
for in, want := range cases {
if got := camelToSnake(in); got != want {
t.Errorf("camelToSnake(%q) = %q, want %q", in, got, want)
}
}
}