chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
195
server/internal/repo/usersession_time_test.go
Normal file
195
server/internal/repo/usersession_time_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话令牌的时间比较。
|
||||
*
|
||||
* 这些用例锁的是一个真实的安全 bug:SQLite 驱动默认把 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("亚秒精度被截断 —— 同秒插入的多行排序会不确定")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user