346 lines
11 KiB
Go
346 lines
11 KiB
Go
package authser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"videoweb/app/appg"
|
|
"videoweb/pub/common/useragent"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var authCookieNamesWritten = []string{
|
|
"token",
|
|
"userId",
|
|
"accountNo",
|
|
"nickName",
|
|
"avatar",
|
|
"recharge",
|
|
"vipType",
|
|
"currentVipExpire",
|
|
"userType",
|
|
"isRegister",
|
|
}
|
|
|
|
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
|
|
var guestCalls atomic.Int32
|
|
var getInfoCalls atomic.Int32
|
|
|
|
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case guestPath:
|
|
guestCalls.Add(1)
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("guest method = %s, want POST", r.Method)
|
|
}
|
|
var request GuestLoginReq
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode guest request: %v", err)
|
|
}
|
|
if request.DevID == "" || request.DeviceID != request.DevID {
|
|
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
|
|
}
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
|
|
case getInfoPath:
|
|
getInfoCalls.Add(1)
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
|
|
default:
|
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
|
|
c, recorder := newAuthTestContext()
|
|
user := SyncUserOnEnter(c)
|
|
|
|
if user == nil || user.Token != "guest-token" {
|
|
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
|
|
}
|
|
if got := guestCalls.Load(); got != 1 {
|
|
t.Errorf("guest calls = %d, want 1", got)
|
|
}
|
|
if got := getInfoCalls.Load(); got != 0 {
|
|
t.Errorf("getInfo calls = %d, want 0", got)
|
|
}
|
|
assertRequestToken(t, c, "guest-token")
|
|
assertSingleAuthCookieSet(t, recorder)
|
|
}
|
|
|
|
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
|
|
var guestCalls atomic.Int32
|
|
var getInfoCalls atomic.Int32
|
|
authorization := make(chan string, 1)
|
|
|
|
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case guestPath:
|
|
guestCalls.Add(1)
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
|
|
case getInfoPath:
|
|
getInfoCalls.Add(1)
|
|
select {
|
|
case authorization <- r.Header.Get("Authorization"):
|
|
default:
|
|
}
|
|
// getInfo may omit token; the existing request token must be preserved.
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
|
|
default:
|
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
|
|
c, recorder := newAuthTestContext(
|
|
&http.Cookie{Name: "token", Value: "existing-token"},
|
|
&http.Cookie{Name: "sid", Value: "existing-sid"},
|
|
)
|
|
first := SyncUserOnEnter(c)
|
|
second := SyncUserOnEnter(c)
|
|
|
|
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
|
|
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
|
|
}
|
|
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
|
|
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
|
|
}
|
|
if got := guestCalls.Load(); got != 0 {
|
|
t.Errorf("guest calls = %d, want 0", got)
|
|
}
|
|
if got := getInfoCalls.Load(); got != 1 {
|
|
t.Errorf("getInfo calls = %d, want 1", got)
|
|
}
|
|
select {
|
|
case got := <-authorization:
|
|
if got != "existing-token" {
|
|
t.Errorf("Authorization = %q, want existing-token", got)
|
|
}
|
|
default:
|
|
t.Error("getInfo did not capture Authorization header")
|
|
}
|
|
assertRequestToken(t, c, "existing-token")
|
|
if got := cookieValue(c, "nickName"); got != "fresh-user" {
|
|
t.Errorf("request nickName cookie = %q, want fresh-user", got)
|
|
}
|
|
assertSingleAuthCookieSet(t, recorder)
|
|
}
|
|
|
|
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
|
|
var guestCalls atomic.Int32
|
|
var getInfoCalls atomic.Int32
|
|
|
|
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case guestPath:
|
|
guestCalls.Add(1)
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
|
|
case getInfoPath:
|
|
getInfoCalls.Add(1)
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
|
|
default:
|
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
|
|
c, recorder := newAuthTestContext()
|
|
guest := EnsureGuestOnEnter(c)
|
|
user := SyncUserOnEnter(c)
|
|
|
|
if guest == nil || guest.Token != "pre-render-token" {
|
|
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
|
|
}
|
|
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
|
|
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
|
|
}
|
|
if got := guestCalls.Load(); got != 1 {
|
|
t.Errorf("guest calls = %d, want 1", got)
|
|
}
|
|
if got := getInfoCalls.Load(); got != 0 {
|
|
t.Errorf("getInfo calls = %d, want 0", got)
|
|
}
|
|
assertRequestToken(t, c, "pre-render-token")
|
|
assertSingleAuthCookieSet(t, recorder)
|
|
}
|
|
|
|
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
|
|
var guestCalls atomic.Int32
|
|
var getInfoCalls atomic.Int32
|
|
getInfoAuthorization := make(chan string, 1)
|
|
guestAuthorization := make(chan string, 1)
|
|
|
|
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case getInfoPath:
|
|
getInfoCalls.Add(1)
|
|
select {
|
|
case getInfoAuthorization <- r.Header.Get("Authorization"):
|
|
default:
|
|
}
|
|
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
|
|
case guestPath:
|
|
guestCalls.Add(1)
|
|
select {
|
|
case guestAuthorization <- r.Header.Get("Authorization"):
|
|
default:
|
|
}
|
|
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
|
|
default:
|
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
|
|
c, recorder := newAuthTestContext(
|
|
&http.Cookie{Name: "token", Value: "expired-guest-token"},
|
|
&http.Cookie{Name: "isRegister", Value: "0"},
|
|
&http.Cookie{Name: "userType", Value: "0"},
|
|
)
|
|
user := RefreshUserOnEnter(c)
|
|
// The rebuilt guest must also be reusable by the page-level sync call.
|
|
synced := SyncUserOnEnter(c)
|
|
|
|
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
|
|
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
|
|
}
|
|
if synced == nil || synced.Token != "rebuilt-guest-token" {
|
|
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
|
|
}
|
|
if got := getInfoCalls.Load(); got != 1 {
|
|
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
|
|
}
|
|
if got := guestCalls.Load(); got != 1 {
|
|
t.Errorf("guest calls = %d, want 1", got)
|
|
}
|
|
select {
|
|
case got := <-getInfoAuthorization:
|
|
if got != "expired-guest-token" {
|
|
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
|
|
}
|
|
default:
|
|
t.Error("getInfo did not capture Authorization header")
|
|
}
|
|
select {
|
|
case got := <-guestAuthorization:
|
|
if got != "" {
|
|
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
|
|
}
|
|
default:
|
|
t.Error("guest login did not capture Authorization header")
|
|
}
|
|
assertRequestToken(t, c, "rebuilt-guest-token")
|
|
assertSingleAuthCookieSet(t, recorder)
|
|
}
|
|
|
|
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
|
|
c, recorder := newAuthTestContext()
|
|
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
|
|
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
|
|
|
|
assertSingleAuthCookieSet(t, recorder)
|
|
assertRequestToken(t, c, "latest-token")
|
|
if got := cookieValue(c, "nickName"); got != "latest-user" {
|
|
t.Errorf("request nickName cookie = %q, want latest-user", got)
|
|
}
|
|
for _, cookie := range recorder.Result().Cookies() {
|
|
if cookie.Name == "token" && cookie.Value != "latest-token" {
|
|
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
previousServerAddr := appg.Conf.ServerAddr
|
|
appg.Conf.ServerAddr = server.URL
|
|
t.Cleanup(func() {
|
|
appg.Conf.ServerAddr = previousServerAddr
|
|
server.Close()
|
|
})
|
|
}
|
|
|
|
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
|
|
for _, cookie := range cookies {
|
|
c.Request.AddCookie(cookie)
|
|
}
|
|
return c, recorder
|
|
}
|
|
|
|
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data *LoginResp `json:"data"`
|
|
}{
|
|
Code: code,
|
|
Msg: http.StatusText(status),
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func testLoginUser(token, nickName string) *LoginResp {
|
|
return &LoginResp{
|
|
Token: token,
|
|
UserID: 101,
|
|
AccountNo: "account-101",
|
|
Email: "user101@example.test",
|
|
NickName: nickName,
|
|
// This field is intentionally large: it must stay out of Set-Cookie headers.
|
|
Introduction: strings.Repeat("简介", 150),
|
|
Avatar: "https://cdn.example.test/avatar.png",
|
|
InviteCode: "INVITE101",
|
|
VipType: 1,
|
|
CurrentVipExpire: "2099-01-01T00:00:00Z",
|
|
ShowCardID: 7,
|
|
ShowCardName: "monthly",
|
|
Type: 0,
|
|
Recharge: 88,
|
|
}
|
|
}
|
|
|
|
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
|
|
t.Helper()
|
|
if got := useragent.TokenFromRequest(c.Request); got != want {
|
|
t.Errorf("request token = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
counts := make(map[string]int)
|
|
for _, cookie := range recorder.Result().Cookies() {
|
|
counts[cookie.Name]++
|
|
}
|
|
for name, count := range counts {
|
|
if count > 1 {
|
|
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
|
|
}
|
|
}
|
|
for _, name := range authCookieNamesWritten {
|
|
if got := counts[name]; got != 1 {
|
|
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
|
|
}
|
|
}
|
|
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
|
|
if got := counts[name]; got != 0 {
|
|
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
|
|
}
|
|
}
|
|
var headerBytes int
|
|
for _, raw := range recorder.Header().Values("Set-Cookie") {
|
|
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
|
|
}
|
|
if headerBytes >= 4096 {
|
|
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
|
|
}
|
|
}
|