package auth import ( "context" "database/sql" "io" "log/slog" "path/filepath" "testing" "time" _ "modernc.org/sqlite" ) // 登录有效期的解析与新默认值:迁移种下的历史占位值 12 从未被任何代码读过, // 按「未配置」处理,否则新装的库会永远停在 12(现场前一晚登录、次日比赛当天掉线)。 func TestEffectiveSessionHours(t *testing.T) { for _, tc := range []struct { name string values map[string]string want int }{ {"历史占位值 12(无面板标记)按未配置处理", map[string]string{SessionHoursKey: "12"}, 24}, {"面板明确选过的 12 原样生效", map[string]string{SessionHoursKey: "12", SessionHoursSourceKey: "panel"}, 12}, {"面板写的 72 生效", map[string]string{SessionHoursKey: "72", SessionHoursSourceKey: "panel"}, 72}, {"未配置回默认 24", map[string]string{}, 24}, {"上界 720", map[string]string{SessionHoursKey: "720"}, 720}, {"下界 1", map[string]string{SessionHoursKey: "1"}, 1}, {"0 非法回默认", map[string]string{SessionHoursKey: "0"}, 24}, {"721 非法回默认", map[string]string{SessionHoursKey: "721"}, 24}, {"非数字非法回默认", map[string]string{SessionHoursKey: "十二"}, 24}, } { t.Run(tc.name, func(t *testing.T) { if got := EffectiveSessionHours(tc.values); got != tc.want { t.Fatalf("EffectiveSessionHours(%v) = %d,期望 %d", tc.values, got, tc.want) } }) } } func TestParseSessionHours(t *testing.T) { for raw, want := range map[string]int{"1": 1, "24": 24, "720": 720, " 48 ": 48} { got, ok := ParseSessionHours(raw) if !ok || got != want { t.Fatalf("ParseSessionHours(%q) = %d,%v,期望 %d,true", raw, got, ok, want) } } for _, raw := range []string{"", "0", "-1", "721", "24.5", "abc"} { if _, ok := ParseSessionHours(raw); ok { t.Fatalf("ParseSessionHours(%q) 应判为非法", raw) } } } // 系统库里的登录有效期要真正被认成会话寿命;显式传入的 Options.SessionTTL 优先(测试 / 嵌入用)。 func TestSessionTTLFromSystemSettings(t *testing.T) { for _, tc := range []struct { name string seed map[string]string optsTTL time.Duration want time.Duration }{ {"历史占位值 12 视为未配置 → 24h", map[string]string{SessionHoursKey: "12"}, 0, 24 * time.Hour}, {"面板写的 1 → 1h", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 0, time.Hour}, {"面板里的值优先于启动参数", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 90 * time.Minute, time.Hour}, {"系统库没有配置时用启动参数兜底", nil, 90 * time.Minute, 90 * time.Minute}, } { t.Run(tc.name, func(t *testing.T) { db := newSettingsDB(t, tc.seed) svc := New(db, Options{Logger: quietLogger(), SessionTTL: tc.optsTTL}) if got := svc.SessionTTL(); got != tc.want { t.Fatalf("SessionTTL() = %s,期望 %s", got, tc.want) } }) } } // 滑动续期:剩余寿命少于「有效期 - 节流窗口」时才回写,且回写后到期时间顺延到 now+有效期。 func TestLookupSlidesExpiryWithThrottle(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSession(t, db, userID, now.Add(24*time.Hour)) // 刚创建的会话:剩余 = 有效期,不写 expires_at(首次请求不该产生额外写)。 if _, sess, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } else if !sess.ExpiresAt.Equal(now.Add(24 * time.Hour)) { t.Fatalf("新鲜会话的到期时间不该变,得到 %s", sess.ExpiresAt) } if got := dbExpiry(t, db); got != now.Add(24*time.Hour).Format(time.DateTime) { t.Fatalf("新鲜会话不应回写 expires_at,库里是 %s", got) } // 剩余 23h58m(不足 24h-1m):下一次请求应把到期时间顺延到 now+24h。 before := now.Add(24*time.Hour - 2*time.Minute) setExpiry(t, db, before) if _, sess, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("应顺延到 %s,得到 %s", want, sess.ExpiresAt) } if got, want := dbExpiry(t, db), now.Add(24*time.Hour).Format(time.DateTime); got != want { t.Fatalf("库里到期时间应为 %s,得到 %s", want, got) } // 节流:紧接着的多次请求既不写 expires_at,也不写 last_seen_at—— // 现场一次页面加载会打七八个请求,逐个写库纯属放大写压力。 renewed := dbExpiry(t, db) seen := dbLastSeen(t, db) for i := 0; i < 5; i++ { now = now.Add(time.Second) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("第 %d 次查询会话: %v", i, err) } if got := dbExpiry(t, db); got != renewed { t.Fatalf("节流窗口内不该回写 expires_at:%s → %s", renewed, got) } if got := dbLastSeen(t, db); got != seen { t.Fatalf("节流窗口内不该回写 last_seen_at:%s → %s", seen, got) } } // 越过节流窗口(1 分钟)后再次顺延:活跃会话大约每分钟才写一次库。 now = now.Add(5 * time.Minute) if _, sess, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt) } if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want { t.Fatalf("续期时活动时间应一起回写为 %s,得到 %s", want, got) } } // 活动时间的节流不会影响续期:会话剩余寿命超过当前有效期(例如有效期被调短)时, // 只回写 last_seen_at,expires_at 保持原值。 func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSession(t, db, userID, now.Add(20*time.Hour)) setLastSeen(t, db, now.Add(-time.Hour)) stored := dbExpiry(t, db) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want { t.Fatalf("活动时间应回写为 %s,得到 %s", want, got) } if got := dbExpiry(t, db); got != stored { t.Fatalf("已登录会话的到期时间不该被改设置的续期逻辑改写:%s → %s", stored, got) } } // 会话复查:/ws 长连接靠它判断背后的会话是否还有效。撤销(登出 / 改密 / 管理员重置密码 / // 停用用户)与到期都必须判为失效,否则被停用的人只要不关页面就能一直收推送。 func TestSessionActiveTracksRevocation(t *testing.T) { now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) for _, tc := range []struct { name string sessionID string prepare func(t *testing.T, db *sql.DB) want bool }{ {"有效会话", "sid", nil, true}, {"会话被撤销(登出 / 改密 / 重置密码都删行)", "sid", func(t *testing.T, db *sql.DB) { execTest(t, db, `DELETE FROM sessions`) }, false}, {"会话已过期", "sid", func(t *testing.T, db *sql.DB) { setExpiry(t, db, now.Add(-time.Second)) }, false}, {"用户已停用", "sid", func(t *testing.T, db *sql.DB) { execTest(t, db, `UPDATE users SET active = 0`) }, false}, {"会话 id 未知", "other", nil, false}, {"会话 id 为空", "", nil, false}, } { t.Run(tc.name, func(t *testing.T) { db := newSettingsDB(t, nil) svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") insertSession(t, db, userID, now.Add(time.Hour)) if tc.prepare != nil { tc.prepare(t, db) } got, err := svc.SessionActive(context.Background(), tc.sessionID) if err != nil { t.Fatalf("复查会话: %v", err) } if got != tc.want { t.Fatalf("SessionActive = %v,期望 %v", got, tc.want) } // 复查是只读判定:不能顺延到期时间,也不能回写活动时间。 if tc.want && dbExpiry(t, db) != now.Add(time.Hour).Format(time.DateTime) { t.Fatalf("复查不该写库,库里到期时间是 %s", dbExpiry(t, db)) } }) } } func execTest(t *testing.T, db *sql.DB, query string, args ...any) { t.Helper() if _, err := db.Exec(query, args...); err != nil { t.Fatalf("执行 %s: %v", query, err) } } // 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。 func TestLookupRenewsAfterThrottleWindow(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute)) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } now = now.Add(2 * time.Minute) if _, sess, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt) } } // 到期校验不能被续期改坏:已过期的会话仍然查不到(调用方据此回 401)。 func TestLookupRejectsExpiredSession(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSession(t, db, userID, now.Add(-time.Minute)) if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { t.Fatalf("过期会话应返回 ErrSessionNotFound,得到 %v", err) } } // 改有效期不动已存在的会话:只换进程内的 TTL,库里的 expires_at 原样保留。 func TestSetSessionTTLKeepsExistingExpiry(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSession(t, db, userID, now.Add(time.Hour)) stored := dbExpiry(t, db) svc.SetSessionTTL(72 * time.Hour) if got := svc.SessionTTL(); got != 72*time.Hour { t.Fatalf("SessionTTL() = %s,期望 72h", got) } if got := dbExpiry(t, db); got != stored { t.Fatalf("改有效期不该动已存在会话的 expires_at:%s → %s", stored, got) } // 旧会话下一次被使用时按新的、更长的有效期顺延(是延长,不是踢下线)。 _, sess, err := svc.Lookup(context.Background(), token) if err != nil { t.Fatalf("旧会话不该被踢下线: %v", err) } if want := now.Add(72 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("旧会话应顺延到 %s,得到 %s", want, sess.ExpiresAt) } } // 新会话按改后的有效期创建。 func TestCreateSessionUsesCurrentTTL(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") svc.SetSessionTTL(720 * time.Hour) if _, sess, err := svc.createSession(context.Background(), userID, "127.0.0.1", "test"); err != nil { t.Fatalf("创建会话: %v", err) } else if want := now.Add(720 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("新会话应在 %s 到期,得到 %s", want, sess.ExpiresAt) } } // ---------- 测试脚手架 ---------- func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } const testSchema = ` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'recorder', active INTEGER NOT NULL DEFAULT 1, must_change_password INTEGER NOT NULL DEFAULT 0, last_login_at TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, ip TEXT, user_agent TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen_at TEXT ); CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );` func newSettingsDB(t *testing.T, seed map[string]string) *sql.DB { t.Helper() db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "system.db")) if err != nil { t.Fatalf("打开测试库: %v", err) } db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if _, err := db.Exec(testSchema); err != nil { t.Fatalf("建表: %v", err) } for k, v := range seed { if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)`, k, v); err != nil { t.Fatalf("写入设置 %s: %v", k, err) } } return db } func insertUser(t *testing.T, db *sql.DB, username string) int64 { t.Helper() res, err := db.Exec(`INSERT INTO users (username, role) VALUES (?, 'admin')`, username) if err != nil { t.Fatalf("建用户: %v", err) } id, _ := res.LastInsertId() return id } func insertSession(t *testing.T, db *sql.DB, userID int64, expiresAt time.Time) string { t.Helper() token := "token-" + expiresAt.Format(time.RFC3339Nano) if _, err := db.Exec( `INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`, "sid", userID, hashToken(token), expiresAt.Format(time.DateTime), expiresAt.Format(time.DateTime)); err != nil { t.Fatalf("建会话: %v", err) } return token } func setExpiry(t *testing.T, db *sql.DB, expiresAt time.Time) { t.Helper() if _, err := db.Exec(`UPDATE sessions SET expires_at = ?`, expiresAt.Format(time.DateTime)); err != nil { t.Fatalf("改到期时间: %v", err) } } func setLastSeen(t *testing.T, db *sql.DB, at time.Time) { t.Helper() if _, err := db.Exec(`UPDATE sessions SET last_seen_at = ?`, at.Format(time.DateTime)); err != nil { t.Fatalf("改活动时间: %v", err) } } func dbExpiry(t *testing.T, db *sql.DB) string { t.Helper() var v string if err := db.QueryRow(`SELECT expires_at FROM sessions`).Scan(&v); err != nil { t.Fatalf("读 expires_at: %v", err) } return v } func dbLastSeen(t *testing.T, db *sql.DB) string { t.Helper() var v string if err := db.QueryRow(`SELECT last_seen_at FROM sessions`).Scan(&v); err != nil { t.Fatalf("读 last_seen_at: %v", err) } return v }