feat: add profile auth identity binding flow

This commit is contained in:
IanShaw027
2026-04-20 18:28:44 +08:00
parent 13d9780df4
commit c6d8592484
31 changed files with 3419 additions and 239 deletions

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -18,7 +19,8 @@ import (
)
type userHandlerRepoStub struct {
user *service.User
user *service.User
identities []service.UserAuthIdentityRecord
}
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
@@ -96,6 +98,11 @@ func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context,
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)
@@ -134,3 +141,135 @@ func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
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 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")
}