mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
28aa466107
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录 - 媒体及剧集详情补充 People 演职员信息解析与返回 - 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑 - 增加临时密码登录相关的单元测试
595 lines
17 KiB
Go
595 lines
17 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/glebarez/sqlite"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
"github.com/truewhile/MeBox/internal/middleware"
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
"github.com/truewhile/MeBox/internal/service"
|
||
)
|
||
|
||
func TestNormalizeEmbyPath(t *testing.T) {
|
||
tests := []struct {
|
||
input string
|
||
wantPath string
|
||
changed bool
|
||
}{
|
||
{
|
||
input: "/emby/System/Info",
|
||
wantPath: "/emby/system/info",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "/emby/emby/System/Info",
|
||
wantPath: "/emby/system/info",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "/emby/emby/emby/items/123/playbackInfo",
|
||
wantPath: "/emby/items/123/playbackinfo",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "//emby//System//Info//Public",
|
||
wantPath: "/emby/system/info/public",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "/Items/msgo-series-1/PlaybackInfo",
|
||
wantPath: "/items/msgo-series-1/playbackinfo",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "/Videos/m-123/Master.m3u8",
|
||
wantPath: "/videos/m-123/master.m3u8",
|
||
changed: true,
|
||
},
|
||
{
|
||
input: "/api/unknown/other",
|
||
wantPath: "/api/unknown/other",
|
||
changed: false,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
gotPath, changed := NormalizeEmbyPath(tt.input)
|
||
if gotPath != tt.wantPath || changed != tt.changed {
|
||
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Emby: service.NewEmbyService(nil, nil, repos),
|
||
}
|
||
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, "secret", svc)
|
||
|
||
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
|
||
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
if !strings.Contains(w.Body.String(), "ServerName") {
|
||
t.Fatalf("expected server info body, got: %s", w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestEmbyMixedCaseHandling(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Emby: service.NewEmbyService(nil, nil, repos),
|
||
}
|
||
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, "secret", svc)
|
||
|
||
// 混合大小写驼峰: /emby/system/Info/Public
|
||
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestEmbyClientIdentification(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
ua string
|
||
query string
|
||
headerAuth string
|
||
wantClient string
|
||
}{
|
||
{
|
||
name: "CapyPlayer via UA",
|
||
ua: "CapyPlayer/1.2.0 (iOS)",
|
||
wantClient: "CapyPlayer",
|
||
},
|
||
{
|
||
name: "SenPlayer via UA",
|
||
ua: "SenPlayer/2.1",
|
||
wantClient: "SenPlayer",
|
||
},
|
||
{
|
||
name: "Fileball via UA",
|
||
ua: "Fileball/1.0.0",
|
||
wantClient: "Fileball",
|
||
},
|
||
{
|
||
name: "Kodi via UA",
|
||
ua: "Kodi/20.2",
|
||
wantClient: "Kodi",
|
||
},
|
||
{
|
||
name: "Client in query",
|
||
ua: "CustomApp/1.0",
|
||
query: "?X-Emby-Client=CapyPlayer",
|
||
wantClient: "CapyPlayer",
|
||
},
|
||
{
|
||
name: "Client in auth header",
|
||
ua: "Custom/1.0",
|
||
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
|
||
wantClient: "SenPlayer",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||
url := "/test"
|
||
if tt.query != "" {
|
||
url += tt.query
|
||
}
|
||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||
if tt.ua != "" {
|
||
req.Header.Set("User-Agent", tt.ua)
|
||
}
|
||
if tt.headerAuth != "" {
|
||
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
|
||
}
|
||
c.Request = req
|
||
|
||
info := embyClientInfoFromRequest(c)
|
||
if info.Client != tt.wantClient {
|
||
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Emby: service.NewEmbyService(nil, nil, repos),
|
||
}
|
||
const secret = "test-secret"
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, secret, svc)
|
||
|
||
token := signedTestToken(t, secret)
|
||
if err := repos.User.Create(t.Context(), &model.User{
|
||
Base: model.Base{ID: "user-1"},
|
||
Username: "tester",
|
||
PasswordHash: "x",
|
||
Role: "admin",
|
||
Tier: "plus",
|
||
IsActive: true,
|
||
}); err != nil {
|
||
t.Fatalf("create user: %v", err)
|
||
}
|
||
if err := db.Create(&model.Media{
|
||
Base: model.Base{ID: "m-adaptive-1"},
|
||
Title: "测试媒体",
|
||
Path: "D:\\media\\test.mkv",
|
||
LibraryID: "lib-1",
|
||
}).Error; err != nil {
|
||
t.Fatalf("create media: %v", err)
|
||
}
|
||
|
||
// 1. 从 /emby 前缀请求 PlaybackInfo
|
||
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||
req1.Header.Set("X-Emby-Token", token)
|
||
w1 := httptest.NewRecorder()
|
||
router.ServeHTTP(w1, req1)
|
||
|
||
if w1.Code != http.StatusOK {
|
||
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
|
||
}
|
||
var res1 struct {
|
||
MediaSources []struct {
|
||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||
} `json:"MediaSources"`
|
||
}
|
||
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
|
||
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
|
||
}
|
||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
|
||
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||
}
|
||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
|
||
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||
}
|
||
|
||
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
|
||
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||
req2.Header.Set("X-Emby-Token", token)
|
||
w2 := httptest.NewRecorder()
|
||
router.ServeHTTP(w2, req2)
|
||
|
||
if w2.Code != http.StatusOK {
|
||
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
|
||
}
|
||
var res2 struct {
|
||
MediaSources []struct {
|
||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||
} `json:"MediaSources"`
|
||
}
|
||
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
|
||
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
|
||
}
|
||
|
||
// 3. 从根路径 /Items 请求 PlaybackInfo
|
||
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
|
||
req3.Header.Set("X-Emby-Token", token)
|
||
w3 := httptest.NewRecorder()
|
||
router.ServeHTTP(w3, req3)
|
||
|
||
if w3.Code != http.StatusOK {
|
||
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestEmbyImageClearNoStore(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Emby: service.NewEmbyService(nil, nil, repos),
|
||
}
|
||
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, "secret", svc)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("placeholder image should return 200, got %d", w.Code)
|
||
}
|
||
cacheControl := w.Header().Get("Cache-Control")
|
||
if strings.Contains(cacheControl, "no-store") {
|
||
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
|
||
}
|
||
if !strings.Contains(cacheControl, "public") {
|
||
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
|
||
}
|
||
}
|
||
|
||
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
cfg := &config.Config{}
|
||
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
|
||
log := zap.NewNop()
|
||
permissions := service.NewPermissionService(log, repos)
|
||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Auth: authSvc,
|
||
Token: tokenSvc,
|
||
Emby: embySvc,
|
||
}
|
||
|
||
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
|
||
if err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
|
||
// 1. 生成 6 位纯数字临时密码 (OTP)
|
||
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
|
||
if err != nil {
|
||
t.Fatalf("create temp password: %v", err)
|
||
}
|
||
if len(code) != 6 || expireSec <= 0 {
|
||
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
|
||
}
|
||
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
|
||
|
||
// 2. 使用临时密码在 Emby 接口登录
|
||
body := `{"Username":"tvuser","Pw":"` + code + `"}`
|
||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
|
||
}
|
||
var loginResp struct {
|
||
AccessToken string `json:"AccessToken"`
|
||
User struct {
|
||
ID string `json:"Id"`
|
||
Name string `json:"Name"`
|
||
} `json:"User"`
|
||
}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
|
||
t.Fatalf("unmarshal login resp: %v", err)
|
||
}
|
||
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
|
||
t.Fatalf("unexpected login payload: %#v", loginResp)
|
||
}
|
||
|
||
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
|
||
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||
req2.Header.Set("Content-Type", "application/json")
|
||
w2 := httptest.NewRecorder()
|
||
router.ServeHTTP(w2, req2)
|
||
|
||
if w2.Code != http.StatusUnauthorized {
|
||
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
|
||
}
|
||
}
|
||
|
||
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
cfg := &config.Config{}
|
||
cfg.Secrets.JWTSecret = "test-secret-compat"
|
||
log := zap.NewNop()
|
||
permissions := service.NewPermissionService(log, repos)
|
||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Auth: authSvc,
|
||
Token: tokenSvc,
|
||
Emby: embySvc,
|
||
}
|
||
|
||
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
|
||
if err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
token, err := authSvc.IssueEmbyToken(user)
|
||
if err != nil {
|
||
t.Fatalf("issue token: %v", err)
|
||
}
|
||
|
||
// 创建 TV Library
|
||
lib := &model.Library{
|
||
Name: "电视剧",
|
||
Path: "/media/电视剧",
|
||
Type: "tv",
|
||
}
|
||
lib.ID = "lib-tv-1"
|
||
if err := db.Create(lib).Error; err != nil {
|
||
t.Fatalf("create lib: %v", err)
|
||
}
|
||
|
||
// 创建 Series
|
||
series := &model.Series{
|
||
LibraryID: "lib-tv-1",
|
||
Title: "Test Drama",
|
||
PosterURL: "https://example.com/series_poster.jpg",
|
||
BackdropURL: "https://example.com/series_backdrop.jpg",
|
||
}
|
||
series.ID = "s-test-1"
|
||
if err := db.Create(series).Error; err != nil {
|
||
t.Fatalf("create series: %v", err)
|
||
}
|
||
|
||
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
|
||
ep := &model.Media{
|
||
LibraryID: "lib-tv-1",
|
||
SeriesID: "s-test-1",
|
||
Title: "Test Episode 1",
|
||
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
DurationSec: 0, // 未知时长
|
||
PosterURL: "https://example.com/ep1_still.jpg",
|
||
}
|
||
ep.ID = "ep-test-1"
|
||
if err := db.Create(ep).Error; err != nil {
|
||
t.Fatalf("create ep: %v", err)
|
||
}
|
||
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||
|
||
// 1. 获取 Series 详情
|
||
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
|
||
reqSeries.Header.Set("X-Emby-Token", token)
|
||
wSeries := httptest.NewRecorder()
|
||
router.ServeHTTP(wSeries, reqSeries)
|
||
if wSeries.Code != http.StatusOK {
|
||
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
|
||
}
|
||
var seriesPayload map[string]any
|
||
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
|
||
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
|
||
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
|
||
}
|
||
if _, ok := seriesPayload["People"]; !ok {
|
||
t.Fatalf("series payload should include People array")
|
||
}
|
||
|
||
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
|
||
// 添加一条播放进度记录 (posMs = 60000)
|
||
hist := &model.PlaybackHistory{
|
||
UserID: user.ID,
|
||
MediaID: ep.ID,
|
||
PositionMs: 60000,
|
||
}
|
||
_ = db.Create(hist).Error
|
||
|
||
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
|
||
reqEp.Header.Set("X-Emby-Token", token)
|
||
wEp := httptest.NewRecorder()
|
||
router.ServeHTTP(wEp, reqEp)
|
||
if wEp.Code != http.StatusOK {
|
||
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
|
||
}
|
||
var epPayload map[string]any
|
||
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
|
||
t.Logf("epPayload: %#v", epPayload)
|
||
|
||
// 验证图片继承
|
||
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
|
||
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
|
||
}
|
||
if epPayload["ParentBackdropItemId"] != "s-test-1" {
|
||
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
|
||
}
|
||
if _, ok := epPayload["People"]; !ok {
|
||
t.Fatalf("ep payload should include People array")
|
||
}
|
||
|
||
// 验证 RunTimeTicks 兜底
|
||
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
|
||
if runTimeTicks <= 0 {
|
||
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
|
||
}
|
||
userData, _ := epPayload["UserData"].(map[string]any)
|
||
playedPct, _ := userData["PlayedPercentage"].(float64)
|
||
if playedPct <= 0 {
|
||
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
|
||
}
|
||
}
|
||
|
||
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
repos := repository.New(db)
|
||
cfg := &config.Config{}
|
||
cfg.Secrets.JWTSecret = "test-secret-temp"
|
||
log := zap.NewNop()
|
||
permissions := service.NewPermissionService(log, repos)
|
||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||
svc := &service.Container{
|
||
Repo: repos,
|
||
Auth: authSvc,
|
||
Token: tokenSvc,
|
||
}
|
||
|
||
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
|
||
if err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
|
||
router := gin.New()
|
||
api := router.Group("/api")
|
||
authed := api.Group("")
|
||
authed.Use(func(c *gin.Context) {
|
||
c.Set(middleware.CtxUserID, user.ID)
|
||
c.Next()
|
||
})
|
||
registerAuthedUserAndLicenseRoutes(authed, svc)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
|
||
}
|
||
var resp struct {
|
||
Code string `json:"code"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||
t.Fatalf("unmarshal resp: %v", err)
|
||
}
|
||
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
|
||
t.Fatalf("invalid temp password resp: %#v", resp)
|
||
}
|
||
|
||
// 验证使用生成的临时密码能登录
|
||
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
|
||
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
|
||
t.Fatalf("login with temp pass failed: %v", err)
|
||
}
|
||
_ = tokens
|
||
}
|