355 lines
12 KiB
Go
355 lines
12 KiB
Go
//go:build unit
|
|
|
|
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type userHandlerRepoStub struct {
|
|
user *service.User
|
|
identities []service.UserAuthIdentityRecord
|
|
}
|
|
|
|
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
|
|
func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) {
|
|
cloned := *s.user
|
|
return &cloned, nil
|
|
}
|
|
func (s *userHandlerRepoStub) GetByEmail(context.Context, string) (*service.User, error) {
|
|
cloned := *s.user
|
|
return &cloned, nil
|
|
}
|
|
func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, error) {
|
|
cloned := *s.user
|
|
return &cloned, nil
|
|
}
|
|
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User) error {
|
|
cloned := *user
|
|
s.user = &cloned
|
|
return nil
|
|
}
|
|
func (s *userHandlerRepoStub) Delete(context.Context, int64) error { return nil }
|
|
func (s *userHandlerRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
|
if s.user == nil || s.user.AvatarURL == "" {
|
|
return nil, nil
|
|
}
|
|
return &service.UserAvatar{
|
|
StorageProvider: s.user.AvatarSource,
|
|
URL: s.user.AvatarURL,
|
|
ContentType: s.user.AvatarMIME,
|
|
ByteSize: s.user.AvatarByteSize,
|
|
SHA256: s.user.AvatarSHA256,
|
|
}, nil
|
|
}
|
|
func (s *userHandlerRepoStub) UpsertUserAvatar(_ context.Context, _ int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
|
s.user.AvatarURL = input.URL
|
|
s.user.AvatarSource = input.StorageProvider
|
|
s.user.AvatarMIME = input.ContentType
|
|
s.user.AvatarByteSize = input.ByteSize
|
|
s.user.AvatarSHA256 = input.SHA256
|
|
return &service.UserAvatar{
|
|
StorageProvider: input.StorageProvider,
|
|
URL: input.URL,
|
|
ContentType: input.ContentType,
|
|
ByteSize: input.ByteSize,
|
|
SHA256: input.SHA256,
|
|
}, nil
|
|
}
|
|
func (s *userHandlerRepoStub) DeleteUserAvatar(context.Context, int64) error {
|
|
s.user.AvatarURL = ""
|
|
s.user.AvatarSource = ""
|
|
s.user.AvatarMIME = ""
|
|
s.user.AvatarByteSize = 0
|
|
s.user.AvatarSHA256 = ""
|
|
return nil
|
|
}
|
|
func (s *userHandlerRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
|
return nil, nil, nil
|
|
}
|
|
func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
|
return nil, nil, nil
|
|
}
|
|
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
|
|
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
|
|
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
|
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
|
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (s *userHandlerRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error {
|
|
return nil
|
|
}
|
|
func (s *userHandlerRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
|
return map[int64]*time.Time{}, nil
|
|
}
|
|
func (s *userHandlerRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
|
return nil
|
|
}
|
|
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
|
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
|
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
|
func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
|
|
out := make([]service.UserAuthIdentityRecord, len(s.identities))
|
|
copy(out, s.identities)
|
|
return out, nil
|
|
}
|
|
|
|
func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
repo := &userHandlerRepoStub{
|
|
user: &service.User{
|
|
ID: 11,
|
|
Email: "handler-avatar@example.com",
|
|
Username: "handler-avatar",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
},
|
|
}
|
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
|
|
|
body := []byte(`{"avatar_url":"https://cdn.example.com/avatar.png"}`)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/user", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
|
|
|
handler.UpdateProfile(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
AvatarURL string `json:"avatar_url"`
|
|
Username string `json:"username"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
|
require.Equal(t, 0, resp.Code)
|
|
require.Equal(t, "https://cdn.example.com/avatar.png", resp.Data.AvatarURL)
|
|
require.Equal(t, "handler-avatar", resp.Data.Username)
|
|
}
|
|
|
|
func TestUserHandlerGetProfileReturnsIdentitySummaries(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
verifiedAt := time.Date(2026, 4, 20, 8, 30, 0, 0, time.UTC)
|
|
repo := &userHandlerRepoStub{
|
|
user: &service.User{
|
|
ID: 11,
|
|
Email: "identity@example.com",
|
|
Username: "identity-user",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
},
|
|
identities: []service.UserAuthIdentityRecord{
|
|
{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo",
|
|
ProviderSubject: "linuxdo-subject-123456",
|
|
VerifiedAt: &verifiedAt,
|
|
Metadata: map[string]any{
|
|
"username": "linuxdo-handle",
|
|
},
|
|
},
|
|
{
|
|
ProviderType: "oidc",
|
|
ProviderKey: "https://issuer.example.com",
|
|
ProviderSubject: "oidc-user-abc",
|
|
Metadata: map[string]any{
|
|
"suggested_display_name": "OIDC Display",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
|
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
|
|
|
handler.GetProfile(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
Identities struct {
|
|
Email struct {
|
|
Bound bool `json:"bound"`
|
|
BoundCount int `json:"bound_count"`
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"email"`
|
|
LinuxDo struct {
|
|
Bound bool `json:"bound"`
|
|
BoundCount int `json:"bound_count"`
|
|
DisplayName string `json:"display_name"`
|
|
ProviderKey string `json:"provider_key"`
|
|
} `json:"linuxdo"`
|
|
OIDC struct {
|
|
Bound bool `json:"bound"`
|
|
DisplayName string `json:"display_name"`
|
|
ProviderKey string `json:"provider_key"`
|
|
} `json:"oidc"`
|
|
WeChat struct {
|
|
Bound bool `json:"bound"`
|
|
CanBind bool `json:"can_bind"`
|
|
BindStartPath string `json:"bind_start_path"`
|
|
} `json:"wechat"`
|
|
} `json:"identities"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
|
require.Equal(t, 0, resp.Code)
|
|
require.True(t, resp.Data.Identities.Email.Bound)
|
|
require.Equal(t, 1, resp.Data.Identities.Email.BoundCount)
|
|
require.Equal(t, "identity@example.com", resp.Data.Identities.Email.DisplayName)
|
|
require.True(t, resp.Data.Identities.LinuxDo.Bound)
|
|
require.Equal(t, 1, resp.Data.Identities.LinuxDo.BoundCount)
|
|
require.Equal(t, "linuxdo-handle", resp.Data.Identities.LinuxDo.DisplayName)
|
|
require.Equal(t, "linuxdo", resp.Data.Identities.LinuxDo.ProviderKey)
|
|
require.True(t, resp.Data.Identities.OIDC.Bound)
|
|
require.Equal(t, "OIDC Display", resp.Data.Identities.OIDC.DisplayName)
|
|
require.Equal(t, "https://issuer.example.com", resp.Data.Identities.OIDC.ProviderKey)
|
|
require.False(t, resp.Data.Identities.WeChat.Bound)
|
|
require.True(t, resp.Data.Identities.WeChat.CanBind)
|
|
require.Contains(t, resp.Data.Identities.WeChat.BindStartPath, "/api/v1/auth/oauth/wechat/start")
|
|
}
|
|
|
|
func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
verifiedAt := time.Date(2026, 4, 20, 8, 30, 0, 0, time.UTC)
|
|
repo := &userHandlerRepoStub{
|
|
user: &service.User{
|
|
ID: 21,
|
|
Email: "legacy-profile@example.com",
|
|
Username: "linuxdo-handle",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
|
AvatarSource: "remote_url",
|
|
},
|
|
identities: []service.UserAuthIdentityRecord{
|
|
{
|
|
ProviderType: "linuxdo",
|
|
ProviderKey: "linuxdo",
|
|
ProviderSubject: "linuxdo-subject-21",
|
|
VerifiedAt: &verifiedAt,
|
|
Metadata: map[string]any{
|
|
"username": "linuxdo-handle",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
|
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 21})
|
|
|
|
handler.GetProfile(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
|
require.Equal(t, 0, resp.Code)
|
|
require.Equal(t, true, resp.Data["email_bound"])
|
|
require.Equal(t, true, resp.Data["linuxdo_bound"])
|
|
require.Equal(t, false, resp.Data["oidc_bound"])
|
|
require.Equal(t, false, resp.Data["wechat_bound"])
|
|
require.Equal(t, "https://cdn.example.com/linuxdo.png", resp.Data["avatar_url"])
|
|
|
|
authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
|
|
require.True(t, ok)
|
|
linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, true, linuxdoBinding["bound"])
|
|
require.Equal(t, "linuxdo", linuxdoBinding["provider"])
|
|
|
|
identityBindings, ok := resp.Data["identity_bindings"].(map[string]any)
|
|
require.True(t, ok)
|
|
emailBinding, ok := identityBindings["email"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, true, emailBinding["bound"])
|
|
|
|
avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "linuxdo", avatarSource["provider"])
|
|
|
|
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
|
require.True(t, ok)
|
|
usernameSource, ok := profileSources["username"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "linuxdo", usernameSource["provider"])
|
|
}
|
|
|
|
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
repo := &userHandlerRepoStub{
|
|
user: &service.User{
|
|
ID: 11,
|
|
Email: "identity@example.com",
|
|
Username: "identity-user",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
},
|
|
}
|
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
|
|
|
body := []byte(`{"provider":"wechat","redirect_to":"/settings/profile"}`)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/auth-identities/bind/start", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
|
|
|
handler.StartIdentityBinding(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
Provider string `json:"provider"`
|
|
AuthorizeURL string `json:"authorize_url"`
|
|
Method string `json:"method"`
|
|
UseBrowserRedirect bool `json:"use_browser_redirect"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
|
require.Equal(t, 0, resp.Code)
|
|
require.Equal(t, "wechat", resp.Data.Provider)
|
|
require.Equal(t, "GET", resp.Data.Method)
|
|
require.True(t, resp.Data.UseBrowserRedirect)
|
|
require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/start")
|
|
require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
|
|
require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
|
|
}
|