package auth import ( "context" "net/http" "net/http/httptest" "testing" "time" ) // refreshResult 是一次 RefreshSessionCookies 写出的 Cookie(按名字索引)。 func refreshResult(t *testing.T, svc *Service, sessionToken, csrfToken string, expiresAt time.Time) map[string]*http.Cookie { t.Helper() rec := httptest.NewRecorder() svc.RefreshSessionCookies(rec, sessionToken, csrfToken, expiresAt) out := map[string]*http.Cookie{} for _, c := range rec.Result().Cookies() { out[c.Name] = c } return out } // 滑动续期之后必须重发 Cookie,而且 Max-Age 要跟着「会话当前还剩多久」走: // // Max-Age 是浏览器端的硬期限。写成完整有效期(登录时那个固定数)时, // 真出现「会话剩余寿命与有效期不一致」的时刻(有效期被改短、会话是旧口径建的), // 浏览器就会在服务端仍然认这条会话的时候把 Cookie 丢掉 —— 用户毫无征兆地被登出, // 而服务端日志上看这条会话一直是活的。这条之前的 0 覆盖正是这个承诺没人守。 func TestRefreshSessionCookiesMaxAgeFollowsRemainingLife(t *testing.T) { now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) db := newSettingsDB(t, nil) svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) // ① 续期恰好到「now + 24h」:Max-Age 是 24h 的秒数。 got := refreshResult(t, svc, "sess-token", "csrf-token", now.Add(24*time.Hour)) sess, csrf := got[CookieSession], got[CookieCSRF] if sess == nil || csrf == nil { t.Fatalf("续期必须同时重发会话与 CSRF Cookie,得到 %v", got) } if sess.Value != "sess-token" || csrf.Value != "csrf-token" { t.Fatalf("Cookie 值必须沿用原值(CSRF 不轮换,避免和飞行中的写请求打架),得到 %q / %q", sess.Value, csrf.Value) } if want := 24 * 60 * 60; sess.MaxAge != want || csrf.MaxAge != want { t.Fatalf("Max-Age 应为剩余寿命 %d 秒,得到会话 %d / CSRF %d", want, sess.MaxAge, csrf.MaxAge) } // ② 关键对照:会话只剩 30 分钟时,Max-Age 必须是 1800,而不是有效期的 86400。 got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(30*time.Minute)) if got[CookieSession] == nil || got[CookieSession].MaxAge != 30*60 { t.Fatalf("Max-Age 应跟会话的剩余寿命(1800),得到 %+v", got[CookieSession]) } if got[CookieSession].MaxAge == 24*60*60 { t.Fatalf("Max-Age 被写成了完整有效期:浏览器会比服务端早 23.5 小时丢掉 Cookie") } // ③ 到期时间已过:一个 Cookie 都不许写(不给已经失效的会话续期限)。 if got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(-time.Second)); len(got) != 0 { t.Fatalf("已过期的会话不该重发任何 Cookie,得到 %v", got) } // ④ 没有会话 token 时不写;CSRF 空串时只写会话(不写一枚空值 Cookie 把票面清掉)。 if got = refreshResult(t, svc, "", "csrf-token", now.Add(time.Hour)); len(got) != 0 { t.Fatalf("没有会话 token 时不该写任何 Cookie,得到 %v", got) } got = refreshResult(t, svc, "sess-token", "", now.Add(time.Hour)) if got[CookieSession] == nil || got[CookieCSRF] != nil { t.Fatalf("CSRF token 为空时只该重发会话 Cookie,得到 %v", got) } // ⑤ 安全属性口径不变:会话 HttpOnly + SameSite=Lax + Path=/,CSRF 可被前端读取。 if !sess.HttpOnly || sess.SameSite != http.SameSiteLaxMode || sess.Path != "/" { t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax + Path=/:%+v", sess) } if csrf.HttpOnly { t.Fatalf("CSRF Cookie 必须可被前端读取(非 HttpOnly):%+v", csrf) } } // Secure 开关(HTTPS 部署)必须作用到重发的两枚 Cookie 上,否则续期一次就退回明文传输。 func TestRefreshSessionCookiesKeepsSecureFlag(t *testing.T) { now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) db := newSettingsDB(t, nil) insecure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }}) secure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Secure: true, Now: func() time.Time { return now }}) for _, tc := range []struct { name string svc *Service secure bool }{ {"默认(HTTP 部署)", insecure, false}, {"Secure=true(HTTPS 部署)", secure, true}, } { t.Run(tc.name, func(t *testing.T) { got := refreshResult(t, tc.svc, "sess-token", "csrf-token", now.Add(time.Hour)) for _, name := range []string{CookieSession, CookieCSRF} { if got[name] == nil { t.Fatalf("应重发 %s,得到 %v", name, got) } if got[name].Secure != tc.secure { t.Fatalf("%s 的 Secure 应为 %v,得到 %+v", name, tc.secure, got[name]) } } }) } } // 停用用户是「立即失效」的第二道闸:管理端撤会话那一步万一没跑到(异常分支、手工改库), // 会话行还在时 Lookup 也必须当它不存在——否则被停用的人只要不关页面就能继续写。 func TestLookupRefusesInactiveUserWithLiveSession(t *testing.T) { now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) db := newSettingsDB(t, nil) 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)) // 会话行还在(到期时间也没动),只把用户停用。 execTest(t, db, `UPDATE users SET active = 0 WHERE id = ?`, userID) if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { t.Fatalf("停用用户的会话应查不到(ErrSessionNotFound),得到 %v", err) } // 对照:重新启用后同一条会话又能用了,证明上面拦的确实是「停用」而不是别的什么。 execTest(t, db, `UPDATE users SET active = 1 WHERE id = ?`, userID) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("重新启用后同一条会话应恢复可用,得到 %v", err) } }