395 lines
14 KiB
Go
395 lines
14 KiB
Go
package cpdaily
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ========== MFA 页面「可用验证方式」解析测试 ==========
|
|
|
|
// 移动端模板样本(结构取自真实 MFA 页面,已脱敏)
|
|
const mfaMobilePageSample = `<!DOCTYPE html><html><body>
|
|
<script>var reAuthParams = {
|
|
"reAuthType":"5",
|
|
"isMultifactor":"true",
|
|
"service":"https:\/\/school.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
|
"reAuthUserId":"20220000000"
|
|
};</script>
|
|
<div class="mobile-reauth-tab-list" id="mobileTabList">
|
|
<div class="mobile-reauth-tab-item mobile-reauth-tab-active" id="mobile_tab_5"
|
|
onclick="handleMobileTabClick(this)" data-type="5" data-index="0">
|
|
<span class="mobile-reauth-tab-text" title="今日校园验证码">今日校园验证码</span>
|
|
</div>
|
|
<div class="mobile-reauth-tab-item" id="mobile_tab_11" onclick="handleMobileTabClick(this)" data-type="11" data-index="1">
|
|
<span class="mobile-reauth-tab-text" title="邮箱验证">邮箱验证</span>
|
|
</div>
|
|
</div>
|
|
</body></html>`
|
|
|
|
func TestParseMFAAuthMethodsMobileTabs(t *testing.T) {
|
|
methods := parseMFAAuthMethods(mfaMobilePageSample)
|
|
if len(methods) != 2 {
|
|
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
|
}
|
|
if methods[0].Type != "5" || methods[0].Name != "今日校园验证码" || !methods[0].Default {
|
|
t.Errorf("第 1 项解析错误: %+v", methods[0])
|
|
}
|
|
if methods[1].Type != "11" || methods[1].Name != "邮箱验证" || methods[1].Default {
|
|
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsNewLayoutWithMoreMenu(t *testing.T) {
|
|
page := `<html><body>
|
|
<div class="reauth-tab-item reauth-tab-active" id="tab_13" data-type="13"></div>
|
|
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
|
<a class="reauth-tab-more-item" id="10">OTP</a>
|
|
</body></html>`
|
|
methods := parseMFAAuthMethods(page)
|
|
if len(methods) != 3 {
|
|
t.Fatalf("期望解析出 3 个方式,实际 %d: %+v", len(methods), methods)
|
|
}
|
|
if methods[0].Type != "13" || !methods[0].Default || methods[0].Name != "WeLink验证码" {
|
|
t.Errorf("第 1 项解析错误(应取 data-type、active 标记、兜底名): %+v", methods[0])
|
|
}
|
|
if methods[1].Type != "3" || methods[1].Name != "短信验证码" {
|
|
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
|
}
|
|
if methods[2].Type != "10" || methods[2].Name != "OTP" {
|
|
t.Errorf("更多菜单项解析错误(id 即编号、文本即名字): %+v", methods[2])
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsLegacyLayout(t *testing.T) {
|
|
page := `<html><body>
|
|
<div class="changeReAuthTypes" id="3"></div>
|
|
<div class="changeReAuthTypes" id="10"></div>
|
|
</body></html>`
|
|
methods := parseMFAAuthMethods(page)
|
|
if len(methods) != 2 {
|
|
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
|
}
|
|
if methods[0].Type != "3" || methods[1].Type != "10" {
|
|
t.Errorf("旧版列表解析错误: %+v", methods)
|
|
}
|
|
}
|
|
|
|
// 许昌学院 xcuTheme:「选择认证方式」弹窗(结构取自真实 MFA 页面,已脱敏)
|
|
const mfaOtherTypeDialogPageSample = `<html><body>
|
|
<script>var reAuthParams = {
|
|
"reAuthType":"3",
|
|
"isMultifactor":"true",
|
|
"service":"https:\/\/xcu.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
|
"reAuthUserId":"5810260823"
|
|
};</script>
|
|
<div id="other_dialog">
|
|
<div class="mask changeOtherType"></div>
|
|
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
|
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
|
<div id="3" class="other_type_button" onclick="changeMobileReAuthType(this)">短信验证码</div>
|
|
<div class="other_type_title cancel_bottom_btn" onclick="mobileChangeTypeCancel();">取消</div>
|
|
</div>
|
|
</div>
|
|
</body></html>`
|
|
|
|
func TestParseMFAAuthMethodsOtherTypeDialog(t *testing.T) {
|
|
methods := parseMFAAuthMethods(mfaOtherTypeDialogPageSample)
|
|
if len(methods) != 1 {
|
|
t.Fatalf("弹窗方式列表应解析出 1 个方式,实际 %d: %+v", len(methods), methods)
|
|
}
|
|
if methods[0].Type != "3" || methods[0].Name != "短信验证码" {
|
|
t.Errorf("弹窗方式解析错误(id 即编号、文本即名字): %+v", methods[0])
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsOtherTypeDialogMulti(t *testing.T) {
|
|
// 多方式弹窗:标题不应被误解析,仅 .other_type_button 计入
|
|
page := `<html><body>
|
|
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
|
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
|
<div id="3" class="other_type_button">短信验证码</div>
|
|
<div id="11" class="other_type_button">邮箱验证</div>
|
|
<div class="other_type_title cancel_bottom_btn">取消</div>
|
|
</div>
|
|
</body></html>`
|
|
methods := parseMFAAuthMethods(page)
|
|
if len(methods) != 2 {
|
|
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
|
}
|
|
if methods[0].Type != "3" || methods[0].Name != "短信验证码" || methods[1].Type != "11" || methods[1].Name != "邮箱验证" {
|
|
t.Errorf("多方式弹窗解析错误: %+v", methods)
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsDedupeAcrossLayouts(t *testing.T) {
|
|
// 同一方式同时出现在 Tab 与弹窗时应去重(保留先出现项)
|
|
page := `<html><body>
|
|
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
|
<div class="other_type_button" id="3">短信验证码</div>
|
|
</body></html>`
|
|
methods := parseMFAAuthMethods(page)
|
|
if len(methods) != 1 || methods[0].Type != "3" {
|
|
t.Fatalf("跨布局重复方式应去重,实际: %+v", methods)
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsIgnoresComments(t *testing.T) {
|
|
page := `<html><body>
|
|
<!-- <div class="mobile-reauth-tab-item" data-type="9"><span class="mobile-reauth-tab-text">QQ扫码</span></div> -->
|
|
<div class="mobile-reauth-tab-item" data-type="5"><span class="mobile-reauth-tab-text">今日校园验证码</span></div>
|
|
</body></html>`
|
|
methods := parseMFAAuthMethods(page)
|
|
if len(methods) != 1 || methods[0].Type != "5" {
|
|
t.Fatalf("注释块内的方式不应被解析,实际: %+v", methods)
|
|
}
|
|
}
|
|
|
|
func TestParseMFAAuthMethodsEmptyPage(t *testing.T) {
|
|
if methods := parseMFAAuthMethods("<html><body><p>no tabs</p></body></html>"); len(methods) != 0 {
|
|
t.Fatalf("无方式列表的页面应返回空,实际: %+v", methods)
|
|
}
|
|
}
|
|
|
|
// ========== 方式列表规范化测试 ==========
|
|
|
|
func TestNormalizeMFAAuthMethodsFiltersNonDynamicCode(t *testing.T) {
|
|
in := []MFAAuthMethod{
|
|
{Type: "2", Name: "密码验证"},
|
|
{Type: "5", Name: "今日校园验证码"},
|
|
{Type: "11", Name: "邮箱验证"},
|
|
{Type: "10", Name: "安全令牌OTP"},
|
|
}
|
|
out := normalizeMFAAuthMethods(in, "5")
|
|
if len(out) != 2 || out[0].Type != "5" || out[1].Type != "11" {
|
|
t.Fatalf("应过滤到仅动态码方式 [5,11],实际: %+v", out)
|
|
}
|
|
if !out[0].Default || out[1].Default {
|
|
t.Errorf("默认项标记错误: %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMFAAuthMethodsKeepsAllWhenNoDynamicCode(t *testing.T) {
|
|
in := []MFAAuthMethod{
|
|
{Type: "2", Name: "密码验证"},
|
|
{Type: "6", Name: "实人认证"},
|
|
}
|
|
out := normalizeMFAAuthMethods(in, "2")
|
|
if len(out) != 2 {
|
|
t.Fatalf("全为非动态码时应保留原列表,实际: %+v", out)
|
|
}
|
|
if !out[0].Default {
|
|
t.Errorf("应按 defaultType 标记默认项: %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMFAAuthMethodsDefaultFallbacks(t *testing.T) {
|
|
// 页面未标记默认 + defaultType 匹配第二项
|
|
in := []MFAAuthMethod{
|
|
{Type: "5", Name: "Today"},
|
|
{Type: "11", Name: "Email"},
|
|
}
|
|
out := normalizeMFAAuthMethods(in, "11")
|
|
if !out[1].Default || out[0].Default {
|
|
t.Fatalf("defaultType=11 应标记第二项: %+v", out)
|
|
}
|
|
// defaultType 不在列表时标记首项
|
|
in2 := []MFAAuthMethod{
|
|
{Type: "5", Name: "Today"},
|
|
{Type: "3", Name: "SMS"},
|
|
}
|
|
out2 := normalizeMFAAuthMethods(in2, "9")
|
|
if !out2[0].Default {
|
|
t.Fatalf("defaultType 不在列表时应标记首项: %+v", out2)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMFAAuthMethodsEmptyListFallback(t *testing.T) {
|
|
// 页面无 Tab / 解析失败且当前方式支持动态码:用当前方式兜底为唯一选项,避免前端无可选
|
|
out := normalizeMFAAuthMethods(nil, "5")
|
|
if len(out) != 1 || out[0].Type != "5" || out[0].Name != "今日校园验证码" || !out[0].Default {
|
|
t.Fatalf("空列表应按 reAuthType=5 兜底为唯一选项,实际: %+v", out)
|
|
}
|
|
// 当前方式不支持动态码(如实人认证)时不造选项,保持空
|
|
if out := normalizeMFAAuthMethods(nil, "6"); len(out) != 0 {
|
|
t.Fatalf("非动态码方式不应兜底,实际: %+v", out)
|
|
}
|
|
// defaultType 为空同样不猜测
|
|
if out := normalizeMFAAuthMethods(nil, ""); len(out) != 0 {
|
|
t.Fatalf("defaultType 为空不应兜底,实际: %+v", out)
|
|
}
|
|
}
|
|
|
|
// ========== SendMFADynamicCode 全流程测试(mock CAS 服务器) ==========
|
|
|
|
func TestSendMFADynamicCode(t *testing.T) {
|
|
var (
|
|
changeCalled bool
|
|
changeForm map[string]string
|
|
codeForm map[string]string
|
|
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
|
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
|
)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/authserver/reAuthCheck/changeReAuthType.do":
|
|
changeCalled = true
|
|
_ = r.ParseForm()
|
|
changeForm = map[string]string{
|
|
"isMultifactor": r.Form.Get("isMultifactor"),
|
|
"reAuthType": r.Form.Get("reAuthType"),
|
|
"service": r.Form.Get("service"),
|
|
}
|
|
_, _ = w.Write([]byte(changeResp))
|
|
case "/authserver/dynamicCode/getDynamicCodeByReauth.do":
|
|
_ = r.ParseForm()
|
|
codeForm = map[string]string{
|
|
"userName": r.Form.Get("userName"),
|
|
"authCodeTypeName": r.Form.Get("authCodeTypeName"),
|
|
}
|
|
_, _ = w.Write([]byte(codeResp))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
host := strings.TrimPrefix(server.URL, "http://")
|
|
|
|
newMFA := func() *MFAContext {
|
|
return &MFAContext{
|
|
BaseURL: server.URL,
|
|
ContextPath: "/authserver",
|
|
Service: "https://school.campusphere.net/iap/loginSuccess?sessionToken=t",
|
|
ReAuthType: "5",
|
|
UserID: "20220000000",
|
|
AuthMethods: []MFAAuthMethod{
|
|
{Type: "5", Name: "今日校园验证码", Default: true},
|
|
{Type: "11", Name: "邮箱验证"},
|
|
},
|
|
CookiesByHost: map[string][]*http.Cookie{
|
|
host: {{Name: "CASTGC", Value: "TGT-test"}},
|
|
},
|
|
}
|
|
}
|
|
reset := func() {
|
|
changeCalled = false
|
|
changeForm = nil
|
|
codeForm = nil
|
|
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
|
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
|
}
|
|
|
|
t.Run("切换到新方式并发送", func(t *testing.T) {
|
|
reset()
|
|
mfa := newMFA()
|
|
result, err := SendMFADynamicCode(context.Background(), mfa, "11")
|
|
if err != nil {
|
|
t.Fatalf("发送失败: %v", err)
|
|
}
|
|
if !changeCalled {
|
|
t.Error("选择了与默认不同的方式,应先调用 changeReAuthType.do")
|
|
}
|
|
if changeForm["isMultifactor"] != "true" || changeForm["reAuthType"] != "11" || changeForm["service"] != mfa.Service {
|
|
t.Errorf("切换参数错误: %+v", changeForm)
|
|
}
|
|
if codeForm["authCodeTypeName"] != "reAuthEmailDynamicCodeType" || codeForm["userName"] != "20220000000" {
|
|
t.Errorf("发码参数错误: %+v", codeForm)
|
|
}
|
|
if result.Mobile != "138****0000" || result.CodeTime != 120 || result.Message != "验证码已发送成功!" {
|
|
t.Errorf("发码结果解析错误: %+v", result)
|
|
}
|
|
if mfa.ReAuthType != "11" {
|
|
t.Errorf("发送成功后应同步 ReAuthType=11,实际 %s", mfa.ReAuthType)
|
|
}
|
|
})
|
|
|
|
t.Run("沿用默认方式不切换", func(t *testing.T) {
|
|
reset()
|
|
mfa := newMFA()
|
|
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
|
if err != nil {
|
|
t.Fatalf("发送失败: %v", err)
|
|
}
|
|
if changeCalled {
|
|
t.Error("选择与默认相同的方式不应调用 changeReAuthType.do")
|
|
}
|
|
if codeForm["authCodeTypeName"] != "reAuthCpdailyDynamicCodeType" {
|
|
t.Errorf("发码参数错误: %+v", codeForm)
|
|
}
|
|
})
|
|
|
|
t.Run("切换响应 code 为字符串1也兼容", func(t *testing.T) {
|
|
reset()
|
|
changeResp = `{"code":"1","data":{"reAuthType":"11"}}`
|
|
mfa := newMFA()
|
|
if _, err := SendMFADynamicCode(context.Background(), mfa, "11"); err != nil {
|
|
t.Fatalf("字符串 code=1 应视为切换成功,实际报错: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("不在可用列表的方式被拒绝", func(t *testing.T) {
|
|
reset()
|
|
mfa := newMFA()
|
|
if _, err := SendMFADynamicCode(context.Background(), mfa, "2"); err == nil {
|
|
t.Fatal("不在列表中的方式应报错")
|
|
}
|
|
})
|
|
|
|
t.Run("列表内非动态码方式被拒绝", func(t *testing.T) {
|
|
reset()
|
|
mfa := newMFA()
|
|
mfa.AuthMethods = append(mfa.AuthMethods, MFAAuthMethod{Type: "6", Name: "实人认证"})
|
|
_, err := SendMFADynamicCode(context.Background(), mfa, "6")
|
|
if err == nil || !strings.Contains(err.Error(), "暂不支持发送验证码") {
|
|
t.Fatalf("非动态码方式应明确报错,实际: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("会话缺失返回过期错误", func(t *testing.T) {
|
|
reset()
|
|
mfa := newMFA()
|
|
mfa.CookiesByHost = nil
|
|
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
|
if !errors.Is(err, ErrMFASessionExpired) {
|
|
t.Fatalf("会话缺失应返回 ErrMFASessionExpired,实际: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("发码失败透传服务端文案", func(t *testing.T) {
|
|
reset()
|
|
codeResp = `{"res":"fail","returnMessage":"发送过于频繁,请稍后再试"}`
|
|
mfa := newMFA()
|
|
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
|
if err == nil || !strings.Contains(err.Error(), "发送过于频繁") {
|
|
t.Fatalf("应透传服务端失败文案,实际: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("倒计时未结束视为已发送", func(t *testing.T) {
|
|
reset()
|
|
codeResp = `{"res":"code_time_fail","returnMessage":"验证码已发送,请勿重复获取","codeTime":60}`
|
|
mfa := newMFA()
|
|
result, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
|
if err != nil {
|
|
t.Fatalf("code_time_fail 应视为发送成功: %v", err)
|
|
}
|
|
if result.CodeTime != 60 {
|
|
t.Errorf("应回传倒计时秒数,实际: %+v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("会话失效错误码映射为过期", func(t *testing.T) {
|
|
reset()
|
|
codeResp = `{"res":"fail","errCode":"206302","returnMessage":"会话失效"}`
|
|
mfa := newMFA()
|
|
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
|
if !errors.Is(err, ErrMFASessionExpired) {
|
|
t.Fatalf("errCode=206302 应映射为 ErrMFASessionExpired,实际: %v", err)
|
|
}
|
|
})
|
|
}
|