Files
jiu/backend/internal/service/web_ticket_test.go
T
wangjia a773a33779 feat: App→网页免登录(一次性换票 SSO)——移动/桌面点链接直达已登录官网
- backend:POST /auth/web-ticket(需登录,签 60s 单次票据,每用户限频)+
  POST /auth/web-ticket/exchange(公开,原子即焚兑换正常登录态,响应同
  /auth/login 另带 shop_code);web_tickets 新表 + user_sessions.kind 列
  (sso 会话:web 平台、refresh 12h 硬到期、不占设备并发配额、设备管理可见
  可踢);清理任务顺带清过期票据;schema.sql/AutoMigrate/testutil 同步
- web:/sso 落地页——兑票写入与 Web 版共享的 localStorage 后跳 redirect;
  redirect 白名单只收本站相对路径(防 open redirect),票据即时从地址栏抹除
- client:launchAuthedWebPath(ref, path) 封装(签票→拼 /sso URL→launchUrl,
  失败降级未登录直开;桌面/移动同一套);授权面板「查看套餐价格」接入

test(backend): web_ticket_test 三用例(兑换+续期/单次即焚/过期拒绝/
sso 不占配额双向验证)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:30:08 +08:00

98 lines
3.3 KiB
Go

package service
// App→网页免登录一次性换票的回归测试:签票/兑票/单次即焚/过期/sso 不占配额。
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func TestWebTicket_ExchangeOnce(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SSO001")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
ticket, ttl, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
assert.NotEmpty(t, ticket)
assert.Equal(t, 60, ttl)
dev := DeviceInfo{Platform: "web", IP: "1.2.3.4", UserAgent: "test"}
pair, u, sh, err := svc.LoginWithWebTicket(ticket, dev)
require.NoError(t, err)
assert.NotEmpty(t, pair.AccessToken)
assert.Equal(t, user.ID, u.ID)
assert.Equal(t, "SSO001", sh.Code)
// 兑出的 refresh token 可正常续期(会话有效)
_, err = svc.RefreshTokens(pair.RefreshToken)
assert.NoError(t, err)
// 单次即焚:二次兑换必失败
_, _, _, err = svc.LoginWithWebTicket(ticket, dev)
assert.ErrorIs(t, err, ErrTicketInvalid)
}
func TestWebTicket_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SSO002")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
// 手动把票据改成已过期
require.NoError(t, db.Model(&model.WebTicket{}).
Where("ticket = ?", ticket).
Update("expires_at", time.Now().Add(-time.Minute)).Error)
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
assert.ErrorIs(t, err, ErrTicketInvalid)
}
func TestWebTicket_SsoSessionNotCountedInQuota(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 2
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "SSO003")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
// 占满 2 个正常配额
_, _, err := svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"})
require.NoError(t, err)
_, _, err = svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d2", Platform: "ios"})
require.NoError(t, err)
// 配额已满仍可换票登录(sso 不受配额限制)
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
require.NoError(t, err, "sso 会话不受设备配额限制")
// sso 会话短时效 + 打标记
var sess model.UserSession
require.NoError(t, db.Where("shop_id = ? AND kind = 'sso'", shop.ID).
First(&sess).Error)
assert.Equal(t, "web", sess.Platform)
assert.WithinDuration(t, time.Now().Add(12*time.Hour), sess.RefreshExpAt,
time.Minute)
// 反向:sso 会话在场时,正常登录的配额统计不把它算进去
_, _, err = svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"}) // 同设备重登复用坑位
assert.NoError(t, err, "sso 会话不占配额,正常登录不受影响")
}