Files
MailUI4Agents/gateway/internal/repo/usersession_time_test.go
JianFeeeee 1767953331 fix(db): SQLite 时间绑定改用 sqlite 格式 + UTC 时区
modernc.org/sqlite 在 writeTimeFormat 为空时回落到 t.String(),写出
`2026-09-03 16:45:38.5 +0800 HKT m=+0.009` —— 带单调时钟后缀,
datetime() 返回 NULL,与 NOW() 的字符串比较必然错。

后果是安全 bug:user_sessions.expires_at 无法被 datetime() 解析,
过期令牌照样通过校验 —— 会话永不过期。

修法是一行 DSN(`_time_format=sqlite&_timezone=UTC`)而不是改 40 处
db.Ts() 绑定点:DSN 是全局的、不可能漏,辅助函数要求每个新绑定点都记得调用。

排除 `_time_format=datetime`:它把亚秒截断,会重新引入「同秒插入多封邮件
排序不确定」——「会话里最早那封」(决定联系人身份)与「最后那封」
(决定最新进展)都会取错行。

新增 usersession_time_test.go 5 例。反向验证:摘掉 DSN 修复后 4 个失败,
报文即「过期令牌竟然解析成功 —— 会话永不过期」。
2026-09-03 21:08:23 +08:00

196 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/**
* 会话令牌的时间比较。
*
* 这些用例锁的是一个真实的安全 bugSQLite 驱动默认把 time.Time 写成
* Go 的 t.String()"2026-09-10 15:10:36.12 +0800 HKT m=+607182.88"
* 而 NOW() 返回 "2006-01-02 15:04:05.000000" 的 UTC 串。
* 两者做字符串比较时 'T'/'+' 与数字的字典序、以及 +0800 与 UTC 的偏移
* 双重错位,结果是:
* - expires_at > NOW() 恒为真 → 令牌永不过期
* - DELETE WHERE expires_at < NOW() 删 0 行 → 过期令牌永久堆积
* 生产库实测两条 user_sessions 都是这个状态。
*
* 修法在 db.sqliteDSN 的 _time_format=sqlite&_timezone=UTC
* 所以这里必须用真实的 db.Connect 路径来验setupTestDB 就是)。
*/
func seedUserForSession(t *testing.T, ctx context.Context, username string) uuid.UUID {
t.Helper()
id := uuid.New()
_, err := db.DB.ExecContext(ctx, `
INSERT INTO users (user_id, username, password_hash, display_name, role, status)
VALUES ($1, $2, 'x', $2, 'user', 'active')`, id, username)
if err != nil {
t.Fatalf("seed user %s: %v", username, err)
}
return id
}
// 存进去的时间必须能被 SQLite 的时间函数解析。
// datetime() 返回 NULL 意味着所有 SQL 侧时间运算(过期判断、日历到点判断)全废。
func TestTimeBindingIsParsableBySQLite(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
uid := seedUserForSession(t, ctx, "alice")
if _, _, err := CreateUserSession(ctx, uid, "probe"); err != nil {
t.Fatalf("create session: %v", err)
}
var bad int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad)
if err != nil {
t.Fatalf("probe: %v", err)
}
if bad != 0 {
var raw string
db.DB.QueryRowContext(ctx,
`SELECT CAST(expires_at AS TEXT) FROM user_sessions LIMIT 1`).Scan(&raw)
t.Fatalf("expires_at 无法被 datetime() 解析(存储为 %q——"+
"所有 SQL 侧时间比较都会静默失效", raw)
}
}
// 新令牌必须被认作有效,且滑动续期后仍然有效。
func TestFreshSessionResolves(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
uid := seedUserForSession(t, ctx, "bob")
token, expires, err := CreateUserSession(ctx, uid, "ua")
if err != nil {
t.Fatalf("create: %v", err)
}
if !expires.After(time.Now()) {
t.Fatalf("新令牌的过期时间应在将来,得到 %v", expires)
}
u, err := ResolveUserSession(ctx, token)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if u.Username != "bob" {
t.Fatalf("解析出的用户应为 bob得到 %q", u.Username)
}
// 滑动续期写回的也是 time.Time格式同样必须正确
var bad int
db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad)
if bad != 0 {
t.Fatal("滑动续期写回的 expires_at 格式不可解析")
}
}
// 过期令牌必须被拒。这是 bug 的核心症状:格式错位时 expires_at > NOW()
// 恒为真,一个 2020 年就过期的令牌照样能登录。
func TestExpiredSessionRejected(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
uid := seedUserForSession(t, ctx, "carol")
token, _, err := CreateUserSession(ctx, uid, "ua")
if err != nil {
t.Fatalf("create: %v", err)
}
// 手工把过期时间推到过去(走同一条 time.Time 绑定路径)
past := time.Now().Add(-1 * time.Hour)
if _, err := db.DB.ExecContext(ctx,
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, token, past); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, err := ResolveUserSession(ctx, token); err == nil {
var raw string
db.DB.QueryRowContext(ctx,
`SELECT CAST(expires_at AS TEXT) FROM user_sessions WHERE token=$1`, token).Scan(&raw)
t.Fatalf("过期令牌竟然解析成功expires_at=%q—— 会话永不过期", raw)
}
}
// 顺手清理必须真的删掉过期行。删 0 行意味着 user_sessions 无限增长,
// 且被盗令牌永远有效。
func TestExpiredSessionsGetPurged(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
uid := seedUserForSession(t, ctx, "dave")
stale, _, err := CreateUserSession(ctx, uid, "ua")
if err != nil {
t.Fatalf("create: %v", err)
}
past := time.Now().Add(-48 * time.Hour)
if _, err := db.DB.ExecContext(ctx,
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, stale, past); err != nil {
t.Fatalf("backdate: %v", err)
}
// 再建一个会话CreateUserSession 内部会顺手清理过期令牌
if _, _, err := CreateUserSession(ctx, uid, "ua2"); err != nil {
t.Fatalf("second create: %v", err)
}
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM user_sessions WHERE token = $1`, stale).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatal("过期令牌没被清理 —— DELETE ... WHERE expires_at < NOW() 匹配不到行")
}
// 没过期的那条不能被误删
var alive int
db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sessions`).Scan(&alive)
if alive != 1 {
t.Fatalf("应剩 1 条有效会话,得到 %d", alive)
}
}
// 时间能原样读回,且亚秒精度不丢。
//
// 精度是有代价地保住的_time_format=datetime 也能让 datetime() 正常工作,
// 但它把时间截断到秒 —— 同秒插入的多封邮件排序就不确定,
// 「会话里最早那封」(决定联系人身份)会取错行。
func TestTimeRoundTripKeepsSubSecond(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, err := db.DB.ExecContext(ctx,
`INSERT INTO agents (agent_name, secret, platform) VALUES ('probe','x','test')`)
if err != nil {
t.Fatalf("seed agent: %v", err)
}
want := time.Date(2026, 9, 3, 8, 45, 38, 123456000, time.UTC)
if _, err := db.DB.ExecContext(ctx,
`UPDATE agents SET last_seen = $1 WHERE agent_name = 'probe'`, want); err != nil {
t.Fatalf("update: %v", err)
}
var got time.Time
if err := db.DB.QueryRowContext(ctx,
`SELECT last_seen FROM agents WHERE agent_name = 'probe'`).Scan(&got); err != nil {
t.Fatalf("scan: %v", err)
}
if !got.UTC().Equal(want) {
t.Fatalf("时间往返不一致:写入 %v读回 %v", want, got.UTC())
}
if got.UTC().Nanosecond() == 0 {
t.Fatal("亚秒精度被截断 —— 同秒插入的多行排序会不确定")
}
}