608 lines
31 KiB
Go
608 lines
31 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
type imResp struct {
|
||
Code int `json:"code"`
|
||
Reason string `json:"reason"`
|
||
Message string `json:"message"`
|
||
Data map[string]any `json:"data"`
|
||
RequestID string `json:"request_id"`
|
||
}
|
||
|
||
type result struct {
|
||
Chapter string
|
||
ID string
|
||
Name string
|
||
Method string
|
||
Path string
|
||
Status int
|
||
Code int
|
||
Reason string
|
||
Pass bool
|
||
Note string
|
||
Body string
|
||
}
|
||
|
||
type audit struct {
|
||
baseURL string
|
||
wsURL string
|
||
apiKey string
|
||
tenantCode string
|
||
client *http.Client
|
||
results []result
|
||
tokenA string
|
||
userA string
|
||
usernameA string
|
||
phoneA string
|
||
passA string
|
||
tokenB string
|
||
userB string
|
||
usernameB string
|
||
phoneB string
|
||
passB string
|
||
groupTopic string
|
||
groupVer int
|
||
groupQR string
|
||
friendReq string
|
||
p2pTopic string
|
||
meQR string
|
||
meQRPurpose string
|
||
meetingID string
|
||
meetingVer int
|
||
dailyID string
|
||
dailyVer int
|
||
monthlyID string
|
||
monthlyVer int
|
||
actionTok string
|
||
abuseReason string
|
||
profileVer int
|
||
privacyVer int
|
||
feedbackCat string
|
||
}
|
||
|
||
func main() {
|
||
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
|
||
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
|
||
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key")
|
||
tenant := flag.String("tenant", "default", "tenant code")
|
||
out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path")
|
||
flag.Parse()
|
||
|
||
a := &audit{
|
||
baseURL: strings.TrimRight(*baseURL, "/"),
|
||
wsURL: *wsURL,
|
||
apiKey: *apiKey,
|
||
tenantCode: *tenant,
|
||
client: &http.Client{
|
||
Timeout: 20 * time.Second,
|
||
Transport: &http.Transport{
|
||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||
},
|
||
},
|
||
}
|
||
a.run()
|
||
if err := os.WriteFile(*out, []byte(a.report()), 0o644); err != nil {
|
||
panic(err)
|
||
}
|
||
fmt.Printf("wrote %s\n", *out)
|
||
}
|
||
|
||
func (a *audit) run() {
|
||
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
|
||
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
|
||
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+suffix, "AuditPass123"
|
||
|
||
a.testHTTP("1", "1.1", "企业码解析", "POST", "/v1/im/tenants/resolve", map[string]any{"tenant_code": a.tenantCode, "platform": "ios", "app_version": "1.0.0"}, "", true, []int{200}, func(r imResp) string {
|
||
return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
|
||
})
|
||
a.register("1", "1.2A", a.usernameA, a.phoneA, a.passA, true)
|
||
a.register("1", "1.2B", a.usernameB, a.phoneB, a.passB, false)
|
||
a.testHTTP("1", "1.6a", "忘记密码发码", "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "reset_password", "phone": a.phoneA}, "", true, []int{200}, func(r imResp) string {
|
||
challenge := asString(r.Data["challenge_id"])
|
||
if challenge == "" {
|
||
return "未返回 challenge_id"
|
||
}
|
||
a.testHTTP("1", "1.6b", "忘记密码验证码校验", "POST", "/v1/im/auth/password-reset/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(rr imResp) string {
|
||
return "reset_token=" + mask(asString(rr.Data["reset_token"]))
|
||
})
|
||
return "challenge_id=" + mask(challenge)
|
||
})
|
||
a.testHTTP("1", "1.7a", "二次身份验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": "close_youth_mode", "channel": "sms"}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||
challenge := asString(r.Data["challenge_id"])
|
||
if challenge == "" {
|
||
challenge = asString(r.Data["id"])
|
||
}
|
||
if challenge != "" {
|
||
a.testHTTP("1", "1.7b", "二次身份验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, a.tokenA, true, []int{200}, func(rr imResp) string {
|
||
a.actionTok = asString(rr.Data["action_token"])
|
||
return "action_token=" + mask(a.actionTok)
|
||
})
|
||
}
|
||
return "challenge_id=" + mask(challenge)
|
||
})
|
||
a.testHTTP("1", "1.8", "实名状态查询", "GET", "/v1/im/security/realname", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("1", "1.8-submit", "实名提交", "POST", "/v1/im/security/realname/submissions", map[string]any{"name": "张三", "id_type": "cn_id", "id_number": "110101199001011234"}, a.tokenA, true, []int{200, 409}, nil)
|
||
a.testHTTP("1", "1.11", "设置独立密码", "POST", "/v1/im/settings/independent-password", map[string]any{"password": "1234", "purpose": "youth_mode", "action_token": a.actionTok}, a.tokenA, true, []int{200, 400, 403}, nil)
|
||
a.testHTTP("1", "1.12", "应用版本查询", "GET", "/v1/im/app/releases/latest?platform=ios¤t_version=1.0.0&channel=stable", nil, "", false, []int{200}, nil)
|
||
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||
|
||
a.testHTTP("2", "2.1", "Bootstrap", "GET", "/v1/im/bootstrap", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||
return fmt.Sprintf("entries=%v limits=%v", r.Data["entries"] != nil, r.Data["limits"] != nil)
|
||
})
|
||
a.testHTTP("2", "2.search", "会话搜索", "GET", "/v1/im/search/conversations?q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("2", "2.sys", "系统通知列表", "GET", "/v1/im/system-notifications?limit=20", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("2", "2.sys-read", "系统通知已读不存在 ID", "POST", "/v1/im/system-notifications/nonexistent/read", map[string]any{"read_at": time.Now().UTC().Format(time.RFC3339)}, a.tokenA, true, []int{200, 404}, nil)
|
||
|
||
a.testHTTP("5", "5.search", "用户搜索 B", "GET", "/v1/im/users/search?q="+url.QueryEscape(a.usernameB)+"&type=account&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("5", "5.profile", "查看 B 资料", "GET", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/profile", nil, a.tokenA, false, []int{200, 403, 404}, nil)
|
||
a.testHTTP("5", "5.user-qr", "查看 B 加好友二维码", "POST", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200, 403, 404}, nil)
|
||
a.testHTTP("5", "5.friend-create", "创建好友申请 A->B", "POST", "/v1/im/friend-requests", map[string]any{"target_user_id": a.userB, "message": "联调好友申请", "source": "search"}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||
a.friendReq = asString(r.Data["request_id"])
|
||
return "request_id=" + a.friendReq
|
||
})
|
||
if a.friendReq != "" {
|
||
a.testHTTP("5", "5.friend-accept", "B 同意好友申请", "POST", "/v1/im/friend-requests/"+url.PathEscape(a.friendReq)+"/accept", map[string]any{"version": 1}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
|
||
a.p2pTopic = asString(r.Data["topic"])
|
||
return "topic=" + a.p2pTopic
|
||
})
|
||
}
|
||
a.testHTTP("3", "3.context", "P2P 会话上下文", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/context", nil, a.tokenA, false, []int{200, 400, 404}, nil)
|
||
a.testHTTP("3", "3.p2p-create", "创建 P2P 会话目标接口", "POST", "/v1/im/conversations/p2p", map[string]any{"peer_user_id": a.userB}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||
a.testHTTP("3", "3.conv-settings", "会话设置查询", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/settings", nil, a.tokenA, false, []int{200, 404, 405}, nil)
|
||
a.testHTTP("3", "3.conv-messages", "会话消息列表", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/messages?since=1&limit=20", nil, a.tokenA, false, []int{200, 404, 405}, nil)
|
||
|
||
a.testHTTP("4", "4.forward-targets", "转发目标列表", "GET", "/v1/im/forward-targets?query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("4", "4.forward", "消息逐条转发", "POST", "/v1/im/messages/forward", map[string]any{"mode": "separate", "sources": []map[string]any{{"topic": a.p2pTopic, "seq": 1}}, "target_topics": []string{"slf"}}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||
a.testHTTP("4", "4.bundle", "合并转发详情", "GET", "/v1/im/forward-bundles/fbundle_missing?cursor=", nil, a.tokenA, false, []int{200, 404, 405}, nil)
|
||
a.testHTTP("4", "4.recall", "消息撤回不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/recall", map[string]any{"version": 1, "reason": "audit"}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
|
||
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
|
||
a.testHTTP("4", "4.receipts", "消息回执查询", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/receipts?cursor=", nil, a.tokenA, false, []int{200, 400, 404, 403}, nil)
|
||
|
||
a.testHTTP("5", "5.friends", "好友列表", "GET", "/v1/im/friends?query=&initial=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("5", "5.requests", "好友申请列表", "GET", "/v1/im/friend-requests?direction=inbound&status=all&cursor=", nil, a.tokenB, false, []int{200}, nil)
|
||
a.testHTTP("5", "5.blocks", "黑名单列表", "GET", "/v1/im/blocks?cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("5", "5.block-put", "拉黑 B", "PUT", "/v1/im/blocks/"+url.PathEscape(a.userB), map[string]any{}, a.tokenA, true, []int{200}, nil)
|
||
a.testHTTP("5", "5.block-del", "解除拉黑 B", "DELETE", "/v1/im/blocks/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
|
||
a.testHTTP("5", "5.me-qr", "我的二维码", "POST", "/v1/im/me/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||
a.meQR = asString(r.Data["qr_token"])
|
||
a.meQRPurpose = "add_friend"
|
||
return "qr_token=" + mask(a.meQR)
|
||
})
|
||
if a.meQR != "" {
|
||
a.testHTTP("5", "5.qr-resolve", "二维码解析", "POST", "/v1/im/qrcodes/resolve", map[string]any{"token": a.meQR, "purpose": a.meQRPurpose}, a.tokenB, true, []int{200}, nil)
|
||
}
|
||
|
||
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "联调群" + suffix, "description": "自动化测试群", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||
a.groupTopic = asString(r.Data["topic"])
|
||
a.groupVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
|
||
})
|
||
groupPath := "/v1/im/groups/" + url.PathEscape(a.groupTopic)
|
||
a.testHTTP("6", "6.list", "群列表", "GET", "/v1/im/groups?category=owned&query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("6", "6.detail", "群详情", "GET", groupPath, nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.patch", "更新群资料", "PATCH", groupPath, map[string]any{"description": "自动化测试群已更新", "version": a.groupVer}, a.tokenA, true, []int{200, 409, 404}, func(r imResp) string {
|
||
if asInt(r.Data["version"]) > 0 {
|
||
a.groupVer = asInt(r.Data["version"])
|
||
}
|
||
return fmt.Sprintf("version=%d", a.groupVer)
|
||
})
|
||
a.testHTTP("6", "6.settings", "群设置", "GET", groupPath+"/settings", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.member-settings", "群个人设置", "PATCH", groupPath+"/member-settings", map[string]any{"pinned": true, "mute_notifications": false}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||
a.testHTTP("6", "6.my-profile", "群内昵称", "PUT", groupPath+"/members/me/profile", map[string]any{"nickname": "联调昵称", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||
a.testHTTP("6", "6.members", "群成员", "GET", groupPath+"/members?query=&cursor=&mentionable=true", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.member-profile", "群成员资料", "GET", groupPath+"/members/"+url.PathEscape(a.userB)+"/profile", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.qrcode", "群二维码", "POST", groupPath+"/qrcodes", map[string]any{"expires_in_seconds": 86400}, a.tokenA, true, []int{200, 404}, func(r imResp) string {
|
||
a.groupQR = asString(r.Data["qr_token"])
|
||
return "qr_token=" + mask(a.groupQR)
|
||
})
|
||
a.testHTTP("6", "6.invite", "群邀请", "POST", groupPath+"/invitations", map[string]any{"user_ids": []string{a.userB}, "message": "邀请入群"}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||
a.testHTTP("6", "6.join-requests", "入群申请列表", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.summary", "群概要", "GET", groupPath+"/summary", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.ann-latest", "群公告最新", "GET", groupPath+"/announcements/latest", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.governance-patch", "更新群治理配置", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||
a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.admins", "管理员列表", "GET", groupPath+"/members?role=admin&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.mutes", "禁言列表", "GET", groupPath+"/mutes?cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||
a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
|
||
|
||
a.testHTTP("7", "7.favorites", "收藏列表", "GET", "/v1/im/favorites?type=all&q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("7", "7.favorite-batch", "批量收藏不存在消息", "POST", "/v1/im/favorites/batch", map[string]any{"messages": []map[string]any{{"topic": a.groupTopic, "seq": 1}}}, a.tokenA, true, []int{200, 400, 403, 404}, nil)
|
||
a.testHTTP("7", "7.message-search", "聊天记录搜索", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
|
||
a.testHTTP("7", "7.media", "会话媒体列表", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/media?type=image&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
|
||
a.testHTTP("7", "7.msg-context", "消息上下文", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/1/context?before=20&after=20", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
|
||
a.testHTTP("7", "7.file-download", "文件下载不存在文件", "GET", "/v0/file/s/nonexistent", nil, a.tokenA, false, []int{200, 400, 401, 403, 404}, nil)
|
||
|
||
a.testHTTP("8", "8.calls", "通话记录列表", "GET", "/v1/im/calls?limit=5", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("8", "8.topic-calls", "会话通话记录", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/calls?limit=5", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
|
||
|
||
a.testHTTP("9", "9.workbench", "工作台", "GET", "/v1/im/workbench", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.depts", "组织部门", "GET", "/v1/im/organization/departments?parent_id=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.org-search-empty", "组织空关键词搜索", "GET", "/v1/im/organization/search?q=", nil, a.tokenA, false, []int{200, 400}, nil)
|
||
start := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
|
||
end := start.Add(time.Hour)
|
||
a.testHTTP("9", "9.meeting-create", "创建会议", "POST", "/v1/im/meetings", map[string]any{"title": "联调会议", "start_at": start.Format(time.RFC3339), "end_at": end.Format(time.RFC3339), "location": "线上", "attendee_user_ids": []string{a.userB}, "reminder_minutes": []int{10}}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||
a.meetingID = asString(r.Data["meeting_id"])
|
||
a.meetingVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("meeting_id=%s version=%d", a.meetingID, a.meetingVer)
|
||
})
|
||
a.testHTTP("9", "9.meeting-list", "会议列表", "GET", "/v1/im/meetings?limit=5", nil, a.tokenA, false, []int{200, 405}, nil)
|
||
if a.meetingID != "" {
|
||
a.testHTTP("9", "9.meeting-get", "会议详情", "GET", "/v1/im/meetings/"+url.PathEscape(a.meetingID), nil, a.tokenA, false, []int{200, 404}, nil)
|
||
}
|
||
today := time.Now().UTC().Format("2006-01-02")
|
||
a.testHTTP("9", "9.daily-list", "日报列表", "GET", "/v1/im/reports/daily?limit=5", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.daily-create", "创建日报草稿", "POST", "/v1/im/reports/daily", map[string]any{"report_date": today}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
|
||
a.dailyID = asString(r.Data["report_id"])
|
||
a.dailyVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("report_id=%s version=%d", a.dailyID, a.dailyVer)
|
||
})
|
||
a.testHTTP("9", "9.daily-stats", "日报统计", "GET", "/v1/im/reports/daily/statistics?date="+today, nil, a.tokenA, false, []int{200}, nil)
|
||
month := time.Now().UTC().Format("2006-01")
|
||
a.testHTTP("9", "9.monthly-list", "月报列表", "GET", "/v1/im/reports/monthly?limit=5", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.monthly-create", "创建月报草稿", "POST", "/v1/im/reports/monthly", map[string]any{"month": month}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
|
||
a.monthlyID = asString(r.Data["report_id"])
|
||
a.monthlyVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("report_id=%s version=%d", a.monthlyID, a.monthlyVer)
|
||
})
|
||
a.testHTTP("9", "9.monthly-stats", "月报统计", "GET", "/v1/im/reports/monthly/statistics?month="+month, nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.mailbox-config", "总经理信箱配置", "GET", "/v1/im/executive-mailbox/config", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("9", "9.mailbox-submit", "总经理信箱提交", "POST", "/v1/im/executive-mailbox/submissions", map[string]any{"subject": "联调建议", "content": "自动化测试提交", "anonymous": false, "attachment_file_ids": []string{}, "notice_version": "v1"}, a.tokenA, true, []int{200, 400, 403}, nil)
|
||
|
||
a.testHTTP("10", "10.me", "我的账号", "GET", "/v1/im/me", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("10", "10.profile", "个人资料", "GET", "/v1/im/me/profile", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||
a.profileVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("version=%d", a.profileVer)
|
||
})
|
||
a.testHTTP("10", "10.profile-patch", "更新个人资料", "PATCH", "/v1/im/me/profile", map[string]any{"nickname": "联调用户A", "signature": "audit", "version": a.profileVer}, a.tokenA, true, []int{200, 409}, nil)
|
||
a.testHTTP("10", "10.privacy", "隐私设置", "GET", "/v1/im/me/privacy", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||
a.privacyVer = asInt(r.Data["version"])
|
||
return fmt.Sprintf("version=%d", a.privacyVer)
|
||
})
|
||
a.testHTTP("10", "10.privacy-patch", "更新隐私设置", "PATCH", "/v1/im/me/privacy", map[string]any{"add_me_methods": map[string]any{"account": true, "phone": true, "qr": true, "group": true}, "profile_visibility": "everyone", "last_seen_visibility": "friends", "read_receipts": true, "version": a.privacyVer}, a.tokenA, true, []int{200, 409}, nil)
|
||
a.testHTTP("10", "10.youth", "青少年模式", "GET", "/v1/im/settings/youth-mode", nil, a.tokenA, false, []int{200}, nil)
|
||
a.testHTTP("10", "10.independent-verify", "独立密码验证", "POST", "/v1/im/settings/independent-password/verify", map[string]any{"password": "1234", "purpose": "youth_mode"}, a.tokenA, true, []int{200, 400, 403}, nil)
|
||
a.testHTTP("10", "10.push", "设备 Push Token", "PUT", "/v1/im/devices/audit-device-"+suffix+"/push-token", map[string]any{"provider": "apns", "token": "audit-token-" + suffix, "platform": "ios", "app_version": "1.0.0"}, a.tokenA, true, []int{200}, nil)
|
||
|
||
a.testHTTP("11", "11.url", "URL 风险检查", "POST", "/v1/im/security/urls/check", map[string]any{"url": "https://example.com"}, a.tokenA, true, []int{200}, nil)
|
||
a.testHTTP("11", "11.abuse-reasons", "举报原因", "GET", "/v1/im/reports/abuse/reasons?target_type=user", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||
if items, ok := r.Data["items"].([]any); ok && len(items) > 0 {
|
||
if first, ok := items[0].(map[string]any); ok {
|
||
a.abuseReason = asString(first["reason_code"])
|
||
if a.abuseReason == "" {
|
||
a.abuseReason = asString(first["code"])
|
||
}
|
||
}
|
||
}
|
||
return "reason=" + a.abuseReason
|
||
})
|
||
a.testHTTP("11", "11.abuse-submit", "提交举报", "POST", "/v1/im/reports/abuse", map[string]any{"target_type": "user", "target_id": a.userB, "reason_code": a.abuseReason, "description": "联调举报", "evidence": []map[string]any{}}, a.tokenA, true, []int{200, 400, 403}, nil)
|
||
a.testHTTP("11", "11.feedback-config", "反馈配置", "GET", "/v1/im/feedback/config", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||
a.feedbackCat = "bug"
|
||
if items, ok := r.Data["categories"].([]any); ok && len(items) > 0 {
|
||
if s := asString(items[0]); s != "" {
|
||
a.feedbackCat = s
|
||
}
|
||
}
|
||
return "category=" + a.feedbackCat
|
||
})
|
||
a.testHTTP("11", "11.feedback", "提交反馈", "POST", "/v1/im/feedback", map[string]any{"category": a.feedbackCat, "content": "自动化联调反馈", "contact": "", "attachment_file_ids": []string{}, "client_context": map[string]any{"source": "imtestaudit"}, "include_diagnostics": true}, a.tokenA, true, []int{200}, nil)
|
||
}
|
||
|
||
func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
|
||
var challenge, verification string
|
||
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) string {
|
||
challenge = asString(r.Data["challenge_id"])
|
||
return "challenge_id=" + mask(challenge)
|
||
})
|
||
if challenge != "" {
|
||
a.testHTTP(chapter, id+"-verify", "注册验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
|
||
verification = asString(r.Data["verification_token"])
|
||
return "verification_token=" + mask(verification)
|
||
})
|
||
}
|
||
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
|
||
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "WebSocket hi+acc 注册 " + username, Method: "WS", Path: "/v0/channels", Status: 0, Code: 0, Pass: err == nil, Note: note}
|
||
if err != nil {
|
||
res.Reason = err.Error()
|
||
}
|
||
a.results = append(a.results, res)
|
||
if primary {
|
||
a.tokenA, a.userA = token, userID
|
||
} else {
|
||
a.tokenB, a.userB = token, userID
|
||
}
|
||
}
|
||
|
||
func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
|
||
u, _ := url.Parse(a.wsURL)
|
||
q := u.Query()
|
||
q.Set("apikey", a.apiKey)
|
||
u.RawQuery = q.Encode()
|
||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
defer conn.Close()
|
||
deadline := time.Now().Add(15 * time.Second)
|
||
_ = conn.SetReadDeadline(deadline)
|
||
_ = conn.SetWriteDeadline(deadline)
|
||
hiID := "hi-" + username
|
||
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{"id": hiID, "ver": "0.25", "ua": "imtestaudit/1.0", "platf": "go", "lang": "zh-CN", "tenant": a.tenantCode}}); err != nil {
|
||
return "", "", "", err
|
||
}
|
||
if _, err = waitCtrl(conn, hiID); err != nil {
|
||
return "", "", "", fmt.Errorf("hi: %w", err)
|
||
}
|
||
accID := "acc-" + username
|
||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||
err = conn.WriteJSON(map[string]any{"acc": map[string]any{
|
||
"id": accID,
|
||
"user": "new",
|
||
"scheme": "basic",
|
||
"username": username,
|
||
"phone": phone,
|
||
"secret": secret,
|
||
"login": true,
|
||
"cred": []map[string]any{{
|
||
"meth": "tel",
|
||
"val": phone,
|
||
"resp": verification,
|
||
"params": map[string]any{"challenge_id": challenge},
|
||
}},
|
||
"desc": map[string]any{"public": map[string]any{"fn": username}},
|
||
}})
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
ctrl, err := waitCtrl(conn, accID)
|
||
if err != nil {
|
||
return "", "", "", fmt.Errorf("acc: %w", err)
|
||
}
|
||
params, _ := ctrl["params"].(map[string]any)
|
||
token := asString(params["token"])
|
||
user := asString(params["user"])
|
||
return token, user, "user=" + user + " token=" + mask(token), nil
|
||
}
|
||
|
||
func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) {
|
||
for i := 0; i < 20; i++ {
|
||
var msg map[string]any
|
||
if err := conn.ReadJSON(&msg); err != nil {
|
||
return nil, err
|
||
}
|
||
ctrl, ok := msg["ctrl"].(map[string]any)
|
||
if !ok || asString(ctrl["id"]) != id {
|
||
continue
|
||
}
|
||
code := asInt(ctrl["code"])
|
||
if code < 200 || code >= 300 {
|
||
return ctrl, fmt.Errorf("ctrl code=%d text=%s", code, asString(ctrl["text"]))
|
||
}
|
||
return ctrl, nil
|
||
}
|
||
return nil, fmt.Errorf("ctrl %s not received", id)
|
||
}
|
||
|
||
func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token string, idem bool, want []int, note func(imResp) string) {
|
||
status, parsed, raw, err := a.call(method, path, body, token, idem)
|
||
r := result{Chapter: chapter, ID: id, Name: name, Method: method, Path: path, Status: status, Code: parsed.Code, Reason: parsed.Reason, Body: compact(raw)}
|
||
if err != nil {
|
||
r.Reason = err.Error()
|
||
} else {
|
||
for _, s := range want {
|
||
if status == s {
|
||
r.Pass = true
|
||
break
|
||
}
|
||
}
|
||
if note != nil {
|
||
r.Note = note(parsed)
|
||
}
|
||
}
|
||
a.results = append(a.results, r)
|
||
}
|
||
|
||
func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) {
|
||
var reader io.Reader
|
||
if body != nil {
|
||
b, _ := json.Marshal(body)
|
||
reader = bytes.NewReader(b)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
defer cancel()
|
||
target := path
|
||
if strings.HasPrefix(path, "/") {
|
||
target = a.baseURL + path
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, target, reader)
|
||
if err != nil {
|
||
return 0, imResp{}, "", err
|
||
}
|
||
req.Header.Set("X-Tinode-APIKey", a.apiKey)
|
||
req.Header.Set("X-IM-Schema", "1")
|
||
req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", ""))
|
||
req.Header.Set("X-Tenant-Code", a.tenantCode)
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
if strings.HasPrefix(path, "/v0/file/") {
|
||
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
|
||
}
|
||
}
|
||
if idem {
|
||
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano()))
|
||
}
|
||
resp, err := a.client.Do(req)
|
||
if err != nil {
|
||
return 0, imResp{}, "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||
var parsed imResp
|
||
_ = json.Unmarshal(b, &parsed)
|
||
return resp.StatusCode, parsed, string(b), nil
|
||
}
|
||
|
||
func (a *audit) report() string {
|
||
var b strings.Builder
|
||
now := time.Now().Format("2006-01-02 15:04:05 -0700")
|
||
fmt.Fprintf(&b, "# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)\n\n")
|
||
fmt.Fprintf(&b, "## 1. 测试范围\n\n")
|
||
fmt.Fprintf(&b, "- 对照文档:`docs/IM-前后端对接文档.md`\n")
|
||
fmt.Fprintf(&b, "- 测试时间:`%s`\n", now)
|
||
fmt.Fprintf(&b, "- 测试环境:`%s`\n", a.baseURL)
|
||
fmt.Fprintf(&b, "- WebSocket:`%s`\n", a.wsURL)
|
||
fmt.Fprintf(&b, "- 租户:`%s`\n", a.tenantCode)
|
||
fmt.Fprintf(&b, "- 测试账号 A:`%s` / `%s` / `%s`\n", a.usernameA, a.phoneA, a.userA)
|
||
fmt.Fprintf(&b, "- 测试账号 B:`%s` / `%s` / `%s`\n", a.usernameB, a.phoneB, a.userB)
|
||
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
|
||
pass, fail := 0, 0
|
||
chapters := map[string][2]int{}
|
||
for _, r := range a.results {
|
||
pair := chapters[r.Chapter]
|
||
if r.Pass {
|
||
pass++
|
||
pair[0]++
|
||
} else {
|
||
fail++
|
||
pair[1]++
|
||
}
|
||
chapters[r.Chapter] = pair
|
||
}
|
||
fmt.Fprintf(&b, "## 2. 总体结论\n\n")
|
||
fmt.Fprintf(&b, "本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `%d` 项,符合预期 `%d` 项,不符合预期 `%d` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。\n\n", len(a.results), pass, fail)
|
||
fmt.Fprintf(&b, "| 章 | 符合预期 | 不符合预期 |\n| --- | ---: | ---: |\n")
|
||
keys := make([]string, 0, len(chapters))
|
||
for k := range chapters {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Slice(keys, func(i, j int) bool { return chapterOrder(keys[i]) < chapterOrder(keys[j]) })
|
||
for _, k := range keys {
|
||
pair := chapters[k]
|
||
fmt.Fprintf(&b, "| %s | %d | %d |\n", k, pair[0], pair[1])
|
||
}
|
||
fmt.Fprintf(&b, "\n## 3. 需要关注的问题\n\n")
|
||
hasFail := false
|
||
for _, r := range a.results {
|
||
if !r.Pass {
|
||
hasFail = true
|
||
fmt.Fprintf(&b, "- `%s` %s `%s`:HTTP `%d`,code `%d`,reason `%s`。%s\n", r.ID, r.Method, r.Path, r.Status, r.Code, r.Reason, r.Note)
|
||
}
|
||
}
|
||
if !hasFail {
|
||
fmt.Fprintf(&b, "- 本轮自动化黑盒测试未发现非预期失败。\n")
|
||
}
|
||
fmt.Fprintf(&b, "\n## 4. 分章明细\n\n")
|
||
for _, k := range keys {
|
||
fmt.Fprintf(&b, "### 第 %s 章\n\n", k)
|
||
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |\n| --- | --- | --- | --- | ---: | --- | --- | --- |\n")
|
||
for _, r := range a.results {
|
||
if r.Chapter != k {
|
||
continue
|
||
}
|
||
status := "通过"
|
||
if !r.Pass {
|
||
status = "未通过"
|
||
}
|
||
reason := strings.TrimSpace(fmt.Sprintf("%d/%s", r.Code, r.Reason))
|
||
fmt.Fprintf(&b, "| `%s` | %s | `%s` | `%s` | %d | `%s` | %s | %s |\n", r.ID, esc(r.Name), r.Method, esc(r.Path), r.Status, esc(reason), status, esc(r.Note))
|
||
}
|
||
fmt.Fprintf(&b, "\n")
|
||
}
|
||
fmt.Fprintf(&b, "## 5. 测试产生的数据\n\n")
|
||
fmt.Fprintf(&b, "- 用户 A:`%s`,username `%s`,phone `%s`\n", a.userA, a.usernameA, a.phoneA)
|
||
fmt.Fprintf(&b, "- 用户 B:`%s`,username `%s`,phone `%s`\n", a.userB, a.usernameB, a.phoneB)
|
||
fmt.Fprintf(&b, "- 好友申请:`%s`\n", a.friendReq)
|
||
fmt.Fprintf(&b, "- P2P Topic:`%s`\n", a.p2pTopic)
|
||
fmt.Fprintf(&b, "- 群 Topic:`%s`\n", a.groupTopic)
|
||
fmt.Fprintf(&b, "- 会议:`%s`\n", a.meetingID)
|
||
fmt.Fprintf(&b, "- 日报草稿:`%s`\n", a.dailyID)
|
||
fmt.Fprintf(&b, "- 月报草稿:`%s`\n", a.monthlyID)
|
||
fmt.Fprintf(&b, "\n这些数据位于测试环境租户 `%s`,可按需清理。\n", a.tenantCode)
|
||
return b.String()
|
||
}
|
||
|
||
func asString(v any) string {
|
||
switch t := v.(type) {
|
||
case string:
|
||
return t
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func asInt(v any) int {
|
||
switch t := v.(type) {
|
||
case int:
|
||
return t
|
||
case float64:
|
||
return int(t)
|
||
case json.Number:
|
||
n, _ := t.Int64()
|
||
return int(n)
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func dig(m map[string]any, path ...string) any {
|
||
var cur any = m
|
||
for _, p := range path {
|
||
obj, ok := cur.(map[string]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
cur = obj[p]
|
||
}
|
||
return cur
|
||
}
|
||
|
||
func mask(s string) string {
|
||
if len(s) <= 8 {
|
||
return s
|
||
}
|
||
return s[:4] + "..." + s[len(s)-4:]
|
||
}
|
||
|
||
func compact(s string) string {
|
||
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||
if len(s) > 220 {
|
||
return s[:220] + "..."
|
||
}
|
||
return s
|
||
}
|
||
|
||
func esc(s string) string {
|
||
s = strings.ReplaceAll(s, "|", "\\|")
|
||
s = strings.ReplaceAll(s, "\n", " ")
|
||
return s
|
||
}
|
||
|
||
func chapterOrder(s string) int {
|
||
var n int
|
||
_, _ = fmt.Sscanf(s, "%d", &n)
|
||
return n
|
||
}
|