8b647fc31a
ci/woodpecker/tag/build Pipeline was successful
上一轮用公开接口做对照实验,确认了服务端响应语义: - 正确加密但 ticket 无效 → 「用户名或密码错误」(解密成功) - 裸 ticket / 乱码密文 → 「参数解码错误」(解密失败) - 空 ticket → 500 同时已排除代码侧原因(Go 与站点 security.js 产物逐字节一致、 chunkSize=126 一致、sec_js 禁缓存、算法自 v1.2.157 未变)。 但「参数解码错误」仍缺一处可见性:CAS 换票拿到的 ticket 本身是什么形态、 加密后几块、服务端原始响应是什么,线上日志都没有。没有这些信息, 下一次复现只能继续猜。 新增(均不含凭证本体): - CAS 换票成功:ticketLen / hasSTPrefix / hasSpace / service - 加密完成:ticketLen / dkLen / encLen / blockCount (blockCount>1 才可能出现多块空格,届时 body 是否被 URL 编码才有区别; 这条日志能一次性回答该问题) - 登录被拒:httpStatus / message / bodySnippet(截断 300 rune) 配套 truncateForLog:按 rune 截断,不切断多字节字符。 测试覆盖短串不截断、超长加标记、中文不产生乱码、边界值。
576 lines
19 KiB
Go
576 lines
19 KiB
Go
package cpdaily
|
||
|
||
import (
|
||
"context"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
)
|
||
|
||
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
|
||
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
|
||
//
|
||
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
|
||
//
|
||
// c = ee(l.value, r.value) // 表单值
|
||
// L = ae(c, r.value) // fields 数组
|
||
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
|
||
// f = le(T, D.value) // → Ne→v→pe,转 FormData
|
||
// await te(f) // POST application_submit (multipart)
|
||
//
|
||
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
|
||
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
|
||
|
||
// newXsgzTestSession 构造一个指向假服务器的会话。
|
||
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
|
||
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
|
||
t.Helper()
|
||
return &XsgzSession{
|
||
Token: "test-access-token",
|
||
RoleID: "role-123",
|
||
baseURL: srv.URL,
|
||
client: resty.New(),
|
||
}
|
||
}
|
||
|
||
// captureMultipart 记录一次提交请求的表单元数据与键值。
|
||
type captureMultipart struct {
|
||
contentType string
|
||
authHeader string
|
||
roleHeader string
|
||
path string
|
||
form map[string]string
|
||
// 出现顺序,用于确认 fields[] 按下标展开
|
||
keys []string
|
||
}
|
||
|
||
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
|
||
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
|
||
t.Helper()
|
||
c.path = r.URL.Path
|
||
c.contentType = r.Header.Get("Content-Type")
|
||
c.authHeader = r.Header.Get("Authorization")
|
||
c.roleHeader = r.Header.Get("roleId")
|
||
c.form = map[string]string{}
|
||
|
||
mediaType, params, err := mime.ParseMediaType(c.contentType)
|
||
if err != nil {
|
||
t.Fatalf("解析 Content-Type 失败: %v", err)
|
||
}
|
||
if mediaType != "multipart/form-data" {
|
||
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
|
||
}
|
||
boundary := params["boundary"]
|
||
if boundary == "" {
|
||
t.Fatal("缺少 multipart boundary")
|
||
}
|
||
mr := multipart.NewReader(r.Body, boundary)
|
||
for {
|
||
part, err := mr.NextPart()
|
||
if err != nil {
|
||
break
|
||
}
|
||
name := part.FormName()
|
||
if name == "" {
|
||
continue // 文件部分
|
||
}
|
||
var sb strings.Builder
|
||
buf := make([]byte, 512)
|
||
for {
|
||
n, rerr := part.Read(buf)
|
||
if n > 0 {
|
||
sb.Write(buf[:n])
|
||
}
|
||
if rerr != nil {
|
||
break
|
||
}
|
||
}
|
||
c.form[name] = sb.String()
|
||
c.keys = append(c.keys, name)
|
||
}
|
||
}
|
||
|
||
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
|
||
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
|
||
cap := &captureMultipart{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
cap.readMultipart(t, r)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
fields := []map[string]interface{}{
|
||
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
|
||
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
|
||
// 空值字段:前端 toFormatFormData 会跳过空字符串
|
||
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
|
||
}
|
||
|
||
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||
ProjectID: "proj-uuid-1",
|
||
StartTime: "2026-10-17 08:00:00",
|
||
EndTime: "2026-10-18 18:00:00",
|
||
Address: "山东农业大学",
|
||
Longitude: "117.12",
|
||
Latitude: "36.19",
|
||
Fields: fields,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("提交失败: %v", err)
|
||
}
|
||
if !strings.Contains(string(body), `"code":0`) {
|
||
t.Fatalf("响应解析异常: %s", body)
|
||
}
|
||
|
||
// 路径与鉴权头
|
||
if cap.path != "/api/leave/student_action/application_submit" {
|
||
t.Fatalf("路径错误: %s", cap.path)
|
||
}
|
||
if cap.authHeader != "test-access-token" {
|
||
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
|
||
}
|
||
if cap.roleHeader != "role-123" {
|
||
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
|
||
}
|
||
|
||
// 顶层字段名必须与前端一致
|
||
wantTop := map[string]string{
|
||
"projectId": "proj-uuid-1",
|
||
"requestType": "LEAVE",
|
||
"startTime": "2026-10-17 08:00:00",
|
||
"endTime": "2026-10-18 18:00:00",
|
||
"address": "山东农业大学",
|
||
"longitude": "117.12",
|
||
"latitude": "36.19",
|
||
}
|
||
for k, want := range wantTop {
|
||
if got := cap.form[k]; got != want {
|
||
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
|
||
}
|
||
}
|
||
|
||
// fields 必须是「数组下标方括号 + 对象键点号」:
|
||
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
|
||
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
|
||
if got := cap.form["fields[0].fieldId"]; got != "f1" {
|
||
t.Fatalf("fields[0].fieldId = %q, want f1", got)
|
||
}
|
||
if got := cap.form["fields[0].value"]; got != "身体不适" {
|
||
t.Fatalf("fields[0].value = %q", got)
|
||
}
|
||
if got := cap.form["fields[1].value"]; got != "回家休息" {
|
||
t.Fatalf("fields[1].value = %q", got)
|
||
}
|
||
// 空值字段的 value 不应出现
|
||
if _, ok := cap.form["fields[2].value"]; ok {
|
||
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
|
||
}
|
||
// 但该字段的非空属性仍应提交
|
||
if got := cap.form["fields[2].fieldId"]; got != "f3" {
|
||
t.Fatalf("fields[2].fieldId = %q", got)
|
||
}
|
||
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
|
||
for k := range cap.form {
|
||
if strings.Count(k, "[") != strings.Count(k, "]") {
|
||
t.Fatalf("键 %q 括号不配对", k)
|
||
}
|
||
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
|
||
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
|
||
}
|
||
}
|
||
// 请假请求不应带销假专有字段
|
||
if _, ok := cap.form["originalId"]; ok {
|
||
t.Fatal("请假不应携带 originalId")
|
||
}
|
||
if _, ok := cap.form["inArea"]; ok {
|
||
t.Fatal("请假不应携带 inArea")
|
||
}
|
||
}
|
||
|
||
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
|
||
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
|
||
cap := &captureMultipart{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
cap.readMultipart(t, r)
|
||
_, _ = w.Write([]byte(`{"code":0}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
|
||
ProjectID: "proj-uuid-2",
|
||
OriginalID: "orig-apply-99",
|
||
Address: "山东农业大学",
|
||
Longitude: "117.12",
|
||
Latitude: "36.19",
|
||
InArea: true,
|
||
CancellationApplyInArea: true,
|
||
Fields: []map[string]interface{}{
|
||
{"fieldId": "c1", "value": "已返校"},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("销假提交失败: %v", err)
|
||
}
|
||
|
||
if got := cap.form["requestType"]; got != "CANCELLATION" {
|
||
t.Fatalf("requestType = %q, want CANCELLATION", got)
|
||
}
|
||
if got := cap.form["originalId"]; got != "orig-apply-99" {
|
||
t.Fatalf("originalId = %q, want orig-apply-99", got)
|
||
}
|
||
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
|
||
if got := cap.form["inArea"]; got != "true" {
|
||
t.Fatalf("inArea = %q, want true", got)
|
||
}
|
||
if got := cap.form["cancellationApplyInArea"]; got != "true" {
|
||
t.Fatalf("cancellationApplyInArea = %q, want true", got)
|
||
}
|
||
// 销假不应带 startTime/endTime(未提供时)
|
||
if _, ok := cap.form["startTime"]; ok {
|
||
t.Fatal("销假未提供 startTime 时不应提交该键")
|
||
}
|
||
if got := cap.form["fields[0].value"]; got != "已返校" {
|
||
t.Fatalf("fields[0].value = %q", got)
|
||
}
|
||
}
|
||
|
||
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
|
||
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
|
||
called := false
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
called = true
|
||
_, _ = w.Write([]byte(`{"code":0}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
|
||
if err == nil {
|
||
t.Fatal("缺少 originalId 应报错")
|
||
}
|
||
if !strings.Contains(err.Error(), "originalId") {
|
||
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
|
||
}
|
||
if called {
|
||
t.Fatal("参数校验失败时不应发起请求")
|
||
}
|
||
}
|
||
|
||
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
|
||
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
t.Fatal("不应发起请求")
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
|
||
t.Fatal("缺少 projectId 应报错")
|
||
}
|
||
}
|
||
|
||
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
|
||
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
|
||
cap := &captureMultipart{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
cap.readMultipart(t, r)
|
||
_, _ = w.Write([]byte(`{"code":0}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
|
||
}); err != nil {
|
||
t.Fatalf("提交失败: %v", err)
|
||
}
|
||
for k := range cap.form {
|
||
if strings.HasPrefix(k, "fields") {
|
||
t.Fatalf("fields 为空时不应提交 %q", k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
|
||
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
|
||
var gotPath, gotLeaveID string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath = r.URL.Path
|
||
gotLeaveID = r.URL.Query().Get("leaveId")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||
"id":"apply-1","projectId":"proj-1","projectName":"病假",
|
||
"status":"休假中","cancellationApplyInArea":true
|
||
}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||
if err != nil {
|
||
t.Fatalf("查询详情失败: %v", err)
|
||
}
|
||
if gotPath != "/api/leave/student_action/leave_detail" {
|
||
t.Fatalf("路径错误: %s", gotPath)
|
||
}
|
||
// 前端用 params 传递,键名必须是 leaveId
|
||
if gotLeaveID != "apply-1" {
|
||
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
|
||
}
|
||
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
|
||
t.Fatalf("详情解析异常: %+v", d)
|
||
}
|
||
if !d.RequiresInArea() {
|
||
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
|
||
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||
if err != nil {
|
||
t.Fatalf("查询详情失败: %v", err)
|
||
}
|
||
if !d.RequiresInArea() {
|
||
t.Fatal("字符串「是」应判定为需要范围内申请")
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
|
||
func TestXsgzFetchLeaveDetailError(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
|
||
t.Fatal("code!=0 应报错")
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
|
||
//
|
||
// 前端不依赖 cas/callback 返回 roleId:
|
||
//
|
||
// getOAuthToken 只存 access_token
|
||
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
|
||
func TestXsgzFetchUserRoleContract(t *testing.T) {
|
||
var gotPath, gotMethod string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath, gotMethod = r.URL.Path, r.Method
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||
"realName":"张三",
|
||
"role":{"roleId":"role-777","roleName":"学生"}
|
||
}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
sess := newXsgzTestSession(t, srv)
|
||
info, err := sess.fetchUserRole(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("获取用户角色失败: %v", err)
|
||
}
|
||
if gotPath != "/api/auth/user" {
|
||
t.Fatalf("路径错误: %s", gotPath)
|
||
}
|
||
if gotMethod != http.MethodPost {
|
||
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
|
||
}
|
||
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
|
||
t.Fatalf("解析异常: %+v", info)
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
|
||
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("失败: %v", err)
|
||
}
|
||
if info.RealName != "李四" {
|
||
t.Fatalf("应回退 xm,实际 %q", info.RealName)
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
|
||
func TestXsgzFetchUserRoleError(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
|
||
t.Fatal("code!=0 应报错")
|
||
}
|
||
}
|
||
|
||
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
|
||
//
|
||
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
|
||
//
|
||
// service.post("/cas/callback", `ticket=${s}`,
|
||
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||
//
|
||
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
|
||
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
|
||
func casCallbackBody(enc string) string {
|
||
return "ticket=" + enc
|
||
}
|
||
|
||
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
|
||
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
|
||
// 用真实加密产物验证:多块时含空格
|
||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||
long := vecXsgzTicket
|
||
for i := 0; i < 10; i++ {
|
||
long += "0123456789"
|
||
}
|
||
enc, err := xsgzEncryptTicket(long, r)
|
||
if err != nil {
|
||
t.Fatalf("加密失败: %v", err)
|
||
}
|
||
if !strings.Contains(enc, " ") {
|
||
t.Skip("该密钥长度下未产生多块,无法验证空格")
|
||
}
|
||
|
||
body := casCallbackBody(enc)
|
||
if strings.Contains(body, "+") {
|
||
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
|
||
}
|
||
if !strings.Contains(body, "ticket="+enc) {
|
||
t.Fatal("body 应为 ticket=<原始密文>")
|
||
}
|
||
// 关键:空格未被编码
|
||
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
|
||
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
|
||
}
|
||
}
|
||
|
||
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
|
||
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
|
||
plain := "ticket=aaa bbb"
|
||
escaped := "ticket=" + url.QueryEscape("aaa bbb")
|
||
if escaped == plain {
|
||
t.Fatal("前提失效:QueryEscape 未改变空格")
|
||
}
|
||
if !strings.Contains(escaped, "+") {
|
||
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
|
||
}
|
||
}
|
||
|
||
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
|
||
//
|
||
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
|
||
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
|
||
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||
"id":"apply-1",
|
||
"project":{"id":"proj-nested","text":"病假"},
|
||
"status":"休假中"
|
||
}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||
if err != nil {
|
||
t.Fatalf("查询详情失败: %v", err)
|
||
}
|
||
if d.ProjectID != "proj-nested" {
|
||
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
|
||
}
|
||
if d.ProjectName != "病假" {
|
||
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
|
||
}
|
||
}
|
||
|
||
// 顶层 projectId 优先于 project.id。
|
||
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
|
||
}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||
if err != nil {
|
||
t.Fatalf("失败: %v", err)
|
||
}
|
||
if d.ProjectID != "proj-top" {
|
||
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
|
||
}
|
||
}
|
||
|
||
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
|
||
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
|
||
func TestCasTicketNameVariants(t *testing.T) {
|
||
cases := []struct{ name, rawQuery string }{
|
||
{"ticket", "ticket=ST-1"},
|
||
{"service_ticket", "service_ticket=ST-2"},
|
||
{"ST", "ST=ST-3"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tk := u.Query().Get("ticket")
|
||
if tk == "" {
|
||
tk = u.Query().Get("service_ticket")
|
||
}
|
||
if tk == "" {
|
||
tk = u.Query().Get("ST")
|
||
}
|
||
if tk == "" {
|
||
t.Fatalf("参数名 %s 未取到 ticket", c.name)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
|
||
func TestTruncateForLog(t *testing.T) {
|
||
if got := truncateForLog("abc", 10); got != "abc" {
|
||
t.Fatalf("短串不应截断,实际 %q", got)
|
||
}
|
||
long := strings.Repeat("中", 400)
|
||
got := truncateForLog(long, 300)
|
||
if !strings.HasSuffix(got, "...(truncated)") {
|
||
t.Fatal("超长应加截断标记")
|
||
}
|
||
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
|
||
body := strings.TrimSuffix(got, "...(truncated)")
|
||
if len([]rune(body)) != 300 {
|
||
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
|
||
}
|
||
for _, r := range body {
|
||
if r != '中' {
|
||
t.Fatalf("出现乱码字符 %q", r)
|
||
}
|
||
}
|
||
if got := truncateForLog("x", 0); got != "" {
|
||
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
|
||
}
|
||
}
|