Files
im_server/cmd/imwsaudit/main.go
T

1218 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
)
type imResp struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data map[string]any `json:"data"`
}
type result struct {
ID string
Name string
Method string
Target string
Pass bool
Note string
}
type audit struct {
baseURL string
wsURL string
apiKey string
tenantCode string
out string
client *http.Client
results []result
usernameA string
phoneA string
passA string
userA string
tokenA string
usernameB string
phoneB string
passB string
userB string
tokenB string
p2pTopic string
bobTopic string
groupTopic string
groupSeq int
groupFile string
fileURL string
messageID string
}
type wsClient struct {
name string
user string
token string
conn *websocket.Conn
pending []map[string]any
}
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", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_websocket_testenv_results_2026-08-21.md", "report path")
flag.Parse()
a := &audit{
baseURL: strings.TrimRight(*baseURL, "/"),
wsURL: *wsURL,
apiKey: *apiKey,
tenantCode: *tenant,
out: *out,
client: &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
},
}
a.run()
if err := os.WriteFile(a.out, []byte(a.report()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", a.out)
}
func (a *audit) run() {
suffix := fmt.Sprintf("%08d", (time.Now().UnixNano()+int64(os.Getpid())*100003)%100000000)
nameSuffix := suffix[len(suffix)-6:]
a.usernameA, a.phoneA, a.passA = "wsa"+nameSuffix, "157"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "wsb"+nameSuffix, "158"+suffix, "AuditPass123"
a.step("WS-01", "注册用户 A: HTTP 验证码 + WS acc", "WS", "/v0/channels", func() string {
user, token, err := a.register(a.usernameA, a.phoneA, a.passA)
if err != nil {
return "FAIL: " + err.Error()
}
a.userA, a.tokenA = user, token
return "user=" + user + " token=" + mask(token)
})
a.step("WS-02", "注册用户 B: HTTP 验证码 + WS acc", "WS", "/v0/channels", func() string {
user, token, err := a.register(a.usernameB, a.phoneB, a.passB)
if err != nil {
return "FAIL: " + err.Error()
}
a.userB, a.tokenB = user, token
return "user=" + user + " token=" + mask(token)
})
a.step("WS-03", "HTTP 建立好友关系生成 P2P Topic", "HTTP", "/v1/im/friend-requests", func() string {
reqID, err := a.createFriendship()
if err != nil {
return "FAIL: " + err.Error()
}
return "request_id=" + reqID + " topic=" + a.p2pTopic
})
var alice, bob *wsClient
a.step("WS-04", "A token login + sub me", "WS", "/v0/channels", func() string {
c, err := a.openLoggedIn("A", a.userA, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
alice = c
return "login user=" + a.userA
})
a.step("WS-05", "B token login + sub me", "WS", "/v0/channels", func() string {
c, err := a.openLoggedIn("B", a.userB, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
bob = c
return "login user=" + a.userB
})
defer func() {
if alice != nil && alice.conn != nil {
_ = alice.conn.Close()
}
if bob != nil && bob.conn != nil {
_ = bob.conn.Close()
}
}()
a.step("WS-06", "A/B 订阅 P2P Topic", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
if err := alice.sub(a.p2pTopic); err != nil {
return "FAIL: A sub: " + err.Error()
}
if err := bob.sub(a.p2pTopic); err != nil {
return "FAIL: B sub: " + err.Error()
}
return "topic=" + a.p2pTopic
})
a.step("WS-07", "A pub 文本消息,B 收到 data", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
seq, bobTopic, err := alice.pubAndPeerReceives(bob, a.p2pTopic, "WS 联调消息 "+suffix)
if err != nil {
return "FAIL: " + err.Error()
}
a.messageID = fmt.Sprintf("%d", seq)
a.bobTopic = bobTopic
return fmt.Sprintf("seq=%d bob_topic=%s", seq, bobTopic)
})
a.step("WS-08", "B 发送 recv/read 回执", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil || a.messageID == "" || a.bobTopic == "" {
return "FAIL: websocket client or seq missing"
}
var seq int
_, _ = fmt.Sscanf(a.messageID, "%d", &seq)
if err := bob.note(a.bobTopic, "recv", seq); err != nil {
return "FAIL: recv: " + err.Error()
}
if err := bob.note(a.bobTopic, "read", seq); err != nil {
return "FAIL: read: " + err.Error()
}
info, err := alice.wait(func(msg map[string]any) bool {
i, ok := msg["info"].(map[string]any)
if !ok || asString(i["what"]) != "read" || asInt(i["seq"]) < seq {
return false
}
topic := asString(i["topic"])
return topic == a.p2pTopic || topic == a.userB
}, 5*time.Second)
if err != nil {
return "FAIL: wait read info: " + err.Error()
}
i := info["info"].(map[string]any)
return fmt.Sprintf("what=%s seq=%d", asString(i["what"]), asInt(i["seq"]))
})
a.step("WS-09", "basic login + sub me", "WS", "/v0/channels", func() string {
c, err := a.openBasicLoggedIn("basic-a", a.usernameA, a.passA)
if err != nil {
return "FAIL: " + err.Error()
}
defer c.conn.Close()
return "basic login ok"
})
a.step("WS-10", "重复 login 被拒绝", "WS", "/v0/channels", func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
code, text, err := alice.sendCtrl("login-duplicate", map[string]any{"login": map[string]any{"id": "login-duplicate", "scheme": "token", "secret": a.tokenA}}, 5*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
if code == 409 || code == 400 {
return fmt.Sprintf("code=%d text=%s", code, text)
}
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
})
a.step("WS-11", "订阅 fnd 搜索 Topic", "WS", "fnd", func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
if err := alice.sub("fnd"); err != nil {
return "FAIL: " + err.Error()
}
return "sub fnd ok"
})
a.step("WS-12", "get me desc/sub/tags/cred", "WS", "me", func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
if _, err := alice.get("me", "desc sub tags cred", nil); err != nil {
return "FAIL: " + err.Error()
}
return "get me ok"
})
a.step("WS-13", "get P2P desc/sub/data/del", "WS", a.p2pTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
if _, err := alice.get(a.p2pTopic, "desc sub data del", map[string]any{"data": map[string]any{"since": 1, "limit": 20}, "del": map[string]any{"since": 1, "limit": 20}}); err != nil {
return "FAIL: " + err.Error()
}
return "get p2p ok"
})
a.step("WS-14", "set P2P private 设置", "WS", a.p2pTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
if err := alice.setPrivate(a.p2pTopic, map[string]any{"mute": false, "pinned": true, "source": "imwsaudit"}); err != nil {
return "FAIL: " + err.Error()
}
return "set private ok"
})
a.step("WS-15", "pub 产品 x-im 基础头", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
text := "WS 产品头消息 " + suffix
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.p2pTopic, map[string]any{"mime": "text/plain", "x-im-schema": 1, "x-im-type": "text"}, text)
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d bob_topic=%s", seq, topic)
})
a.step("WS-16", "pub 产品 x-im-client-mid 幂等头", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
text := "WS 幂等头消息 " + suffix
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.p2pTopic, map[string]any{"mime": "text/plain", "x-im-schema": 1, "x-im-type": "text", "x-im-client-mid": "mid-" + suffix}, text)
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d bob_topic=%s", seq, topic)
})
a.step("WS-17", "note kp 输入中通知", "WS", a.bobTopic, func() string {
if alice == nil || bob == nil || a.bobTopic == "" {
return "FAIL: websocket client missing"
}
if err := bob.note(a.bobTopic, "kp", 0); err != nil {
return "FAIL: " + err.Error()
}
msg, err := alice.wait(func(msg map[string]any) bool {
i, ok := msg["info"].(map[string]any)
return ok && asString(i["what"]) == "kp"
}, 5*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
i := msg["info"].(map[string]any)
return "what=" + asString(i["what"])
})
a.step("WS-18", "del 当前用户消息视图", "WS", a.p2pTopic, func() string {
if alice == nil || a.messageID == "" {
return "FAIL: websocket client or seq missing"
}
var seq int
_, _ = fmt.Sscanf(a.messageID, "%d", &seq)
if err := alice.delMsg(a.p2pTopic, seq); err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("deleted seq=%d", seq)
})
a.step("WS-19", "leave P2P detach 后重新订阅", "WS", a.p2pTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
if err := alice.leave(a.p2pTopic, false); err != nil {
return "FAIL: leave: " + err.Error()
}
if err := alice.sub(a.p2pTopic); err != nil {
return "FAIL: resub: " + err.Error()
}
return "leave/resub ok"
})
a.step("WS-20", "HTTP 创建群供 WS 群协议测试", "HTTP", "/v1/im/groups", func() string {
topic, err := a.createGroup(suffix)
if err != nil {
return "FAIL: " + err.Error()
}
a.groupTopic = topic
return "topic=" + topic
})
a.step("WS-21", "A/B 订阅群 Topic", "WS", a.groupTopic, func() string {
if a.groupTopic == "" {
return "FAIL: websocket client or group missing"
}
// Reconnect before the group-specific checks. A read timeout permanently
// poisons a gorilla/websocket connection and must not cascade into this section.
if alice != nil && alice.conn != nil {
_ = alice.conn.Close()
}
if bob != nil && bob.conn != nil {
_ = bob.conn.Close()
}
var err error
alice, err = a.openLoggedIn("A-group", a.userA, a.tokenA)
if err != nil {
return "FAIL: A reconnect: " + err.Error()
}
bob, err = a.openLoggedIn("B-group", a.userB, a.tokenB)
if err != nil {
return "FAIL: B reconnect: " + err.Error()
}
if err := alice.sub(a.groupTopic); err != nil {
return "FAIL: A sub group: " + err.Error()
}
if err := bob.sub(a.groupTopic); err != nil {
return "FAIL: B sub group: " + err.Error()
}
return "sub group ok"
})
a.step("WS-22", "群 pub 文本广播", "WS", a.groupTopic, func() string {
if alice == nil || bob == nil || a.groupTopic == "" {
return "FAIL: websocket client or group missing"
}
text := "WS 群消息 " + suffix
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text)
if err != nil {
return "FAIL: " + err.Error()
}
a.groupSeq = seq
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
})
a.step("WS-22E", "群消息 client_mid 幂等去重", "WS", a.groupTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix}
first, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix)
if err != nil {
return "FAIL: first pub: " + err.Error()
}
second, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix)
if err != nil {
return "FAIL: duplicate pub: " + err.Error()
}
if first <= 0 || second != first {
return fmt.Sprintf("FAIL: first_seq=%d duplicate_seq=%d", first, second)
}
return fmt.Sprintf("first_seq=%d duplicate_seq=%d", first, second)
})
a.step("WS-22F", "群消息上下文查询", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/context", func() string {
time.Sleep(500 * time.Millisecond)
status, r, err := a.call("GET", fmt.Sprintf("/v1/im/topics/%s/messages/%d/context?before=5&after=5", url.PathEscape(a.groupTopic), a.groupSeq), nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
anchor, count := asInt(r.Data["anchor_seq"]), len(asSlice(r.Data["items"]))
if anchor != a.groupSeq || count == 0 {
return fmt.Sprintf("FAIL: anchor_seq=%d items=%d", anchor, count)
}
return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count)
})
a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string {
status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("WS 群消息")+"&type=text", nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
count := len(asSlice(r.Data["items"]))
if count == 0 {
return "FAIL: items=0"
}
return fmt.Sprintf("items=%d", count)
})
a.step("WS-22H", "群消息发送者撤回", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/recall", func() string {
status, r, err := a.call("POST", fmt.Sprintf("/v1/im/topics/%s/messages/%d/recall", url.PathEscape(a.groupTopic), a.groupSeq), map[string]any{"version": 1, "reason": "WS 群消息撤回联调"}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return fmt.Sprintf("seq=%d status=%s version=%d", asInt(r.Data["seq"]), asString(r.Data["status"]), asInt(r.Data["version"]))
})
a.step("WS-22A", "HTTP 禁言群成员 B", "HTTP", "/v1/im/groups/{topic}/mutes/batch", func() string {
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/batch", map[string]any{
"user_ids": []string{a.userB}, "reason": "WS 禁言策略联调",
}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return "member B muted"
})
a.step("WS-22B", "被禁言成员 B pub 被拒绝", "WS", a.groupTopic, func() string {
if bob == nil {
return "FAIL: websocket client missing"
}
id := "pub-muted-" + suffix
code, text, err := bob.sendCtrl(id, map[string]any{"pub": map[string]any{
"id": id, "topic": a.groupTopic, "head": map[string]any{"mime": "text/plain"}, "content": "本消息不应发出",
}}, 15*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
if code != http.StatusForbidden {
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
}
return fmt.Sprintf("code=%d text=%s", code, text)
})
a.step("WS-22C", "HTTP 解除群成员 B 禁言", "HTTP", "/v1/im/groups/{topic}/mutes/{user_id}", func() string {
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
return "member B unmuted"
})
a.step("WS-22D", "解除禁言后 B pub,A 收到 data", "WS", a.groupTopic, func() string {
if alice == nil || bob == nil {
return "FAIL: websocket client missing"
}
seq, topic, err := bob.pubWithHeadAndPeer(alice, a.groupTopic, map[string]any{"mime": "text/plain", "x-im-client-mid": "unmute-" + suffix}, "解除禁言消息 "+suffix)
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
})
a.step("WS-23", "get 群 desc/sub/data", "WS", a.groupTopic, func() string {
if alice == nil || a.groupTopic == "" {
return "FAIL: websocket client or group missing"
}
if _, err := alice.get(a.groupTopic, "desc sub data", map[string]any{"data": map[string]any{"since": 1, "limit": 20}}); err != nil {
return "FAIL: " + err.Error()
}
return "get group ok"
})
a.step("WS-24", "文件上传 /v0/file/u", "HTTP", "/v0/file/u/", func() string {
fileURL, err := a.uploadFile()
if err != nil {
return "FAIL: " + err.Error()
}
a.fileURL = fileURL
return "url=" + mask(fileURL)
})
a.step("WS-24A", "群文件关联 OSS 上传文件", "HTTP", "/v1/im/groups/{topic}/files", func() string {
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files", map[string]any{
"file_id": a.fileURL, "file_name": "imwsaudit.txt", "mime_type": "text/plain", "file_type": "document",
}, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK {
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
}
a.groupFile = asString(r.Data["group_file_id"])
if a.groupFile == "" {
return "FAIL: missing group_file_id"
}
return "group_file_id=" + a.groupFile
})
a.step("WS-24B", "群文件列表可查询", "HTTP", "/v1/im/groups/{topic}/files", func() string {
status, r, err := a.call("GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files?query=imwsaudit&type=document", nil, a.tokenB)
if err != nil {
return "FAIL: " + err.Error()
}
count := len(asSlice(r.Data["items"]))
if status != http.StatusOK || count == 0 {
return fmt.Sprintf("FAIL: status=%d reason=%s items=%d", status, r.Reason, count)
}
return fmt.Sprintf("items=%d", count)
})
a.step("WS-24C", "群主删除群文件关联", "HTTP", "/v1/im/groups/{topic}/files/{group_file_id}", func() string {
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files/"+url.PathEscape(a.groupFile), nil, a.tokenA)
if err != nil {
return "FAIL: " + err.Error()
}
if status != http.StatusOK || asString(r.Data["status"]) != "deleted" {
return fmt.Sprintf("FAIL: status=%d reason=%s file_status=%s", status, r.Reason, asString(r.Data["status"]))
}
return "status=deleted"
})
a.step("WS-25", "WS 文件消息下发", "WS", a.p2pTopic, func() string {
if alice == nil || bob == nil || a.fileURL == "" {
return "FAIL: websocket client or file missing"
}
content := map[string]any{
"txt": "文件",
"ent": []map[string]any{{
"tp": "EX",
"data": map[string]any{
"mime": "text/plain",
"name": "imwsaudit.txt",
"ref": a.fileURL,
"size": 24,
},
}},
}
seq, topic, err := alice.pubContentWithPeer(bob, a.p2pTopic, map[string]any{"mime": "text/x-drafty", "x-im-schema": 1, "x-im-type": "file"}, content)
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d bob_topic=%s", seq, topic)
})
a.step("WS-26", "WebRTC 通话 invite pub", "WS", a.p2pTopic, func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
callID := "call-" + suffix
seq, err := alice.pubCtrl(a.p2pTopic, map[string]any{"mime": "application/json", "webrtc": "started", "x-im-type": "call"}, map[string]any{"callId": callID, "callType": "audio", "audio": true, "video": false})
if err != nil {
return "FAIL: " + err.Error()
}
return fmt.Sprintf("seq=%d call_id=%s", seq, callID)
})
a.step("WS-27", "未 hi 直接 login 被拒绝", "WS", "/v0/channels", func() string {
c, err := a.openWS("no-hi")
if err != nil {
return "FAIL: " + err.Error()
}
defer c.conn.Close()
code, text, err := c.sendCtrl("login-no-hi", map[string]any{"login": map[string]any{"id": "login-no-hi", "scheme": "token", "secret": a.tokenA}}, 5*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
if code >= 400 {
return fmt.Sprintf("code=%d text=%s", code, text)
}
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
})
a.step("WS-28", "hi 后切换 tenant 被拒绝", "WS", "/v0/channels", func() string {
if alice == nil {
return "FAIL: websocket client missing"
}
code, text, err := alice.sendCtrl("hi-switch", map[string]any{"hi": map[string]any{"id": "hi-switch", "ver": "0.25", "tenant": "other"}}, 5*time.Second)
if err != nil {
return "FAIL: " + err.Error()
}
if code >= 400 {
return fmt.Sprintf("code=%d text=%s", code, text)
}
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
})
a.step("WS-29", "非法 API Key 拒绝建连", "WS", "/v0/channels", func() string {
u := a.wsEndpoint("bad-api-key")
_, resp, err := websocket.DefaultDialer.Dial(u, nil)
if err == nil {
return "FAIL: unexpected websocket upgrade"
}
if resp != nil && resp.StatusCode == http.StatusForbidden {
return "http_status=403"
}
if resp != nil {
return fmt.Sprintf("FAIL: http_status=%d err=%v", resp.StatusCode, err)
}
return "FAIL: " + err.Error()
})
}
func (a *audit) step(id, name, method, target string, fn func() string) {
note := fn()
pass := !strings.HasPrefix(note, "FAIL:")
if !pass {
note = strings.TrimSpace(strings.TrimPrefix(note, "FAIL:"))
}
a.results = append(a.results, result{ID: id, Name: name, Method: method, Target: target, Pass: pass, Note: note})
}
func (a *audit) register(username, phone, password string) (string, string, error) {
challenge, err := a.verificationCode(phone)
if err != nil {
return "", "", err
}
verification, err := a.verifyCode(challenge)
if err != nil {
return "", "", err
}
c, err := a.openWS("reg-" + username)
if err != nil {
return "", "", err
}
defer c.conn.Close()
if err = c.hi(a.tenantCode); err != nil {
return "", "", err
}
id := "acc-" + username
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
if err = c.write(map[string]any{"acc": map[string]any{
"id": id,
"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}},
}}); err != nil {
return "", "", err
}
ctrl, err := c.waitCtrl(id, 15*time.Second)
if err != nil {
return "", "", err
}
params, _ := ctrl["params"].(map[string]any)
user, token := asString(params["user"]), asString(params["token"])
if user == "" || token == "" {
return "", "", fmt.Errorf("acc missing user or token")
}
return user, token, nil
}
func (a *audit) verificationCode(phone string) (string, error) {
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "")
if err != nil {
return "", err
}
if status != 200 {
return "", fmt.Errorf("verification status=%d reason=%s", status, r.Reason)
}
challenge := asString(r.Data["challenge_id"])
if challenge == "" {
return "", fmt.Errorf("verification missing challenge_id")
}
return challenge, nil
}
func (a *audit) verifyCode(challenge string) (string, error) {
status, r, err := a.call("POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "")
if err != nil {
return "", err
}
if status != 200 {
return "", fmt.Errorf("verify status=%d reason=%s", status, r.Reason)
}
token := asString(r.Data["verification_token"])
if token == "" {
return "", fmt.Errorf("verify missing verification_token")
}
return token, nil
}
func (a *audit) createFriendship() (string, error) {
status, r, err := a.call("POST", "/v1/im/friend-requests", map[string]any{"target_user_id": a.userB, "message": "WS 联调好友申请", "source": "search"}, a.tokenA)
if err != nil {
return "", err
}
if status != 200 {
return "", fmt.Errorf("friend create status=%d reason=%s", status, r.Reason)
}
reqID := asString(r.Data["request_id"])
if reqID == "" {
return "", fmt.Errorf("friend create missing request_id")
}
status, r, err = a.call("POST", "/v1/im/friend-requests/"+url.PathEscape(reqID)+"/accept", map[string]any{"version": 1}, a.tokenB)
if err != nil {
return "", err
}
if status != 200 && status != 409 {
return "", fmt.Errorf("friend accept status=%d reason=%s", status, r.Reason)
}
a.p2pTopic = asString(r.Data["topic"])
if a.p2pTopic == "" {
return "", fmt.Errorf("friend accept missing topic")
}
return reqID, nil
}
func (a *audit) createGroup(suffix string) (string, error) {
status, r, err := a.call("POST", "/v1/im/groups", map[string]any{
"name": "WS协议群" + suffix,
"description": "WS audit group",
"member_user_ids": []string{a.userB},
"avatar_file_id": "",
}, a.tokenA)
if err != nil {
return "", err
}
if status != 200 {
return "", fmt.Errorf("group create status=%d reason=%s", status, r.Reason)
}
topic := asString(r.Data["topic"])
if topic == "" {
return "", fmt.Errorf("group create missing topic")
}
return topic, nil
}
func (a *audit) uploadFile() (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("id", "upload-"+fmt.Sprintf("%d", time.Now().UnixNano()))
_ = writer.WriteField("auth", "token")
_ = writer.WriteField("secret", a.tokenA)
part, err := writer.CreateFormFile("file", "imwsaudit.txt")
if err != nil {
return "", err
}
if _, err = part.Write([]byte("hello from imwsaudit\n")); err != nil {
return "", err
}
if err = writer.Close(); err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, a.baseURL+"/v0/file/u/", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var parsed map[string]any
_ = json.Unmarshal(raw, &parsed)
ctrl, _ := parsed["ctrl"].(map[string]any)
if resp.StatusCode != http.StatusOK || asInt(ctrl["code"]) < 200 || asInt(ctrl["code"]) >= 300 {
return "", fmt.Errorf("upload status=%d code=%d text=%s body=%s", resp.StatusCode, asInt(ctrl["code"]), asString(ctrl["text"]), compact(string(raw)))
}
params, _ := ctrl["params"].(map[string]any)
fileURL := asString(params["url"])
if fileURL == "" {
return "", fmt.Errorf("upload missing url")
}
return fileURL, nil
}
func (a *audit) openLoggedIn(name, user, token string) (*wsClient, error) {
c, err := a.openWS(name)
if err != nil {
return nil, err
}
if err = c.hi(a.tenantCode); err != nil {
_ = c.conn.Close()
return nil, err
}
loginID := "login-" + strings.ToLower(name)
if err = c.write(map[string]any{"login": map[string]any{
"id": loginID,
"scheme": "token",
"secret": token,
}}); err != nil {
_ = c.conn.Close()
return nil, err
}
if _, err = c.waitCtrl(loginID, 15*time.Second); err != nil {
_ = c.conn.Close()
return nil, fmt.Errorf("login: %w", err)
}
c.user, c.token = user, token
if err = c.sub("me"); err != nil {
_ = c.conn.Close()
return nil, fmt.Errorf("sub me: %w", err)
}
return c, nil
}
func (a *audit) openBasicLoggedIn(name, username, password string) (*wsClient, error) {
c, err := a.openWS(name)
if err != nil {
return nil, err
}
if err = c.hi(a.tenantCode); err != nil {
_ = c.conn.Close()
return nil, err
}
loginID := "login-" + strings.ToLower(name)
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
if err = c.write(map[string]any{"login": map[string]any{
"id": loginID,
"scheme": "basic",
"secret": secret,
}}); err != nil {
_ = c.conn.Close()
return nil, err
}
if _, err = c.waitCtrl(loginID, 15*time.Second); err != nil {
_ = c.conn.Close()
return nil, fmt.Errorf("basic login: %w", err)
}
if err = c.sub("me"); err != nil {
_ = c.conn.Close()
return nil, fmt.Errorf("sub me: %w", err)
}
return c, nil
}
func (a *audit) openWS(name string) (*wsClient, error) {
conn, _, err := websocket.DefaultDialer.Dial(a.wsEndpoint(a.apiKey), nil)
if err != nil {
return nil, err
}
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
return &wsClient{name: name, conn: conn}, nil
}
func (a *audit) wsEndpoint(apiKey string) string {
u, _ := url.Parse(a.wsURL)
q := u.Query()
q.Set("apikey", apiKey)
u.RawQuery = q.Encode()
return u.String()
}
func (c *wsClient) hi(tenant string) error {
id := "hi-" + strings.ToLower(c.name)
if err := c.write(map[string]any{"hi": map[string]any{"id": id, "ver": "0.25", "ua": "imwsaudit/1.0", "platf": "go", "lang": "zh-CN", "tenant": tenant}}); err != nil {
return err
}
_, err := c.waitCtrl(id, 15*time.Second)
return err
}
func (c *wsClient) sub(topic string) error {
id := "sub-" + strings.ToLower(c.name) + "-" + strings.ReplaceAll(topic, ":", "_")
if err := c.write(map[string]any{"sub": map[string]any{
"id": id,
"topic": topic,
"get": map[string]any{"what": "desc sub data", "data": map[string]any{"limit": 10}},
}}); err != nil {
return err
}
_, err := c.waitCtrl(id, 15*time.Second)
return err
}
func (c *wsClient) get(topic, what string, extra map[string]any) (map[string]any, error) {
id := "get-" + strings.ToLower(c.name) + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
get := map[string]any{"id": id, "topic": topic, "what": what}
for k, v := range extra {
get[k] = v
}
if err := c.write(map[string]any{"get": get}); err != nil {
return nil, err
}
if _, err := c.waitCtrl(id, 15*time.Second); err != nil {
return nil, err
}
meta, err := c.wait(func(msg map[string]any) bool {
m, ok := msg["meta"].(map[string]any)
return ok && asString(m["topic"]) == topic
}, 5*time.Second)
if err != nil {
return nil, err
}
return meta["meta"].(map[string]any), nil
}
func (c *wsClient) setPrivate(topic string, private map[string]any) error {
id := "set-" + strings.ToLower(c.name) + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
if err := c.write(map[string]any{"set": map[string]any{
"id": id,
"topic": topic,
"sub": map[string]any{"private": private},
}}); err != nil {
return err
}
ctrl, err := c.waitCtrlRaw(id, 15*time.Second)
if err != nil {
return err
}
code := asInt(ctrl["code"])
if (code >= 200 && code < 300) || code == 304 {
return nil
}
return fmt.Errorf("ctrl id=%s code=%d text=%s", id, code, asString(ctrl["text"]))
}
func (c *wsClient) pubAndPeerReceives(peer *wsClient, topic, text string) (int, string, error) {
id := "pub-" + time.Now().Format("150405.000000000")
if err := c.write(map[string]any{"pub": map[string]any{
"id": id,
"topic": topic,
"head": map[string]any{"mime": "text/plain"},
// Keep this as native MeetingIM text content. Product-specific x-im
// headers should be covered by a separate WS message-header matrix.
"content": text,
}}); err != nil {
return 0, "", err
}
if _, err := c.waitCtrl(id, 15*time.Second); err != nil {
return 0, "", fmt.Errorf("pub ctrl: %w", err)
}
msg, err := peer.wait(func(msg map[string]any) bool {
d, ok := msg["data"].(map[string]any)
return ok && asString(d["content"]) == text
}, 15*time.Second)
if err != nil {
return 0, "", fmt.Errorf("peer data: %w", err)
}
d := msg["data"].(map[string]any)
seq := asInt(d["seq"])
if seq <= 0 {
return 0, "", fmt.Errorf("data missing seq")
}
return seq, asString(d["topic"]), nil
}
func (c *wsClient) pubWithHeadAndPeer(peer *wsClient, topic string, head map[string]any, text string) (int, string, error) {
return c.pubContentWithPeer(peer, topic, head, text)
}
func (c *wsClient) pubContentWithPeer(peer *wsClient, topic string, head map[string]any, content any) (int, string, error) {
seq, err := c.pubCtrl(topic, head, content)
if err != nil {
return 0, "", err
}
msg, err := peer.wait(func(msg map[string]any) bool {
d, ok := msg["data"].(map[string]any)
if !ok || asInt(d["seq"]) != seq {
return false
}
if s, ok := content.(string); ok {
return asString(d["content"]) == s
}
return true
}, 15*time.Second)
if err != nil {
return 0, "", fmt.Errorf("peer data: %w", err)
}
d := msg["data"].(map[string]any)
return seq, asString(d["topic"]), nil
}
func (c *wsClient) pubCtrl(topic string, head map[string]any, content any) (int, error) {
id := "pub-" + time.Now().Format("150405.000000000")
ctrl, err := c.writeAndWaitCtrl(id, map[string]any{"pub": map[string]any{
"id": id,
"topic": topic,
"head": head,
"content": content,
}}, 15*time.Second)
if err != nil {
return 0, fmt.Errorf("pub ctrl: %w", err)
}
params, _ := ctrl["params"].(map[string]any)
seq := asInt(params["seq"])
return seq, nil
}
func (c *wsClient) note(topic, what string, seq int) error {
return c.write(map[string]any{"note": map[string]any{"topic": topic, "what": what, "seq": seq}})
}
func (c *wsClient) delMsg(topic string, seq int) error {
id := "del-" + strings.ToLower(c.name) + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
return c.writeAndWaitOK(id, map[string]any{"del": map[string]any{
"id": id,
"topic": topic,
"what": "msg",
"delseq": []map[string]any{{"low": seq, "hi": seq + 1}},
}}, 15*time.Second)
}
func (c *wsClient) leave(topic string, unsub bool) error {
id := "leave-" + strings.ToLower(c.name) + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
return c.writeAndWaitOK(id, map[string]any{"leave": map[string]any{
"id": id,
"topic": topic,
"unsub": unsub,
}}, 15*time.Second)
}
func (c *wsClient) write(msg map[string]any) error {
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.conn.WriteJSON(msg)
}
func (c *wsClient) sendCtrl(id string, msg map[string]any, timeout time.Duration) (int, string, error) {
if err := c.write(msg); err != nil {
return 0, "", err
}
ctrl, err := c.waitCtrlRaw(id, timeout)
if err != nil {
return 0, "", err
}
return asInt(ctrl["code"]), asString(ctrl["text"]), nil
}
func (c *wsClient) writeAndWaitOK(id string, msg map[string]any, timeout time.Duration) error {
_, err := c.writeAndWaitCtrl(id, msg, timeout)
return err
}
func (c *wsClient) writeAndWaitCtrl(id string, msg map[string]any, timeout time.Duration) (map[string]any, error) {
if err := c.write(msg); err != nil {
return nil, err
}
return c.waitCtrl(id, timeout)
}
func (c *wsClient) waitCtrl(id string, timeout time.Duration) (map[string]any, error) {
ctrl, err := c.waitCtrlRaw(id, timeout)
if err != nil {
return nil, err
}
code := asInt(ctrl["code"])
if code < 200 || code >= 300 {
return ctrl, fmt.Errorf("ctrl id=%s code=%d text=%s", id, code, asString(ctrl["text"]))
}
return ctrl, nil
}
func (c *wsClient) waitCtrlRaw(id string, timeout time.Duration) (map[string]any, error) {
msg, err := c.wait(func(msg map[string]any) bool {
ctrl, ok := msg["ctrl"].(map[string]any)
return ok && asString(ctrl["id"]) == id
}, timeout)
if err != nil {
return nil, err
}
return msg["ctrl"].(map[string]any), nil
}
func (c *wsClient) wait(match func(map[string]any) bool, timeout time.Duration) (map[string]any, error) {
for i := 0; i < len(c.pending); i++ {
if match(c.pending[i]) {
msg := c.pending[i]
c.pending = append(c.pending[:i], c.pending[i+1:]...)
return msg, nil
}
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = c.conn.SetReadDeadline(deadline)
var msg map[string]any
if err := c.conn.ReadJSON(&msg); err != nil {
return nil, err
}
if match(msg) {
return msg, nil
}
c.pending = append(c.pending, msg)
}
return nil, fmt.Errorf("timeout")
}
func (a *audit) call(method, path string, body any, token string) (int, imResp, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, a.baseURL+path, reader)
if err != nil {
return 0, imResp{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "req-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano()))
if method != http.MethodGet {
req.Header.Set("Idempotency-Key", "idem-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano()))
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
}
resp, err := a.client.Do(req)
if err != nil {
return 0, imResp{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var parsed imResp
_ = json.Unmarshal(raw, &parsed)
return resp.StatusCode, parsed, nil
}
func (a *audit) report() string {
total, passed := len(a.results), 0
for _, r := range a.results {
if r.Pass {
passed++
}
}
sort.SliceStable(a.results, func(i, j int) bool { return a.results[i].ID < a.results[j].ID })
var b strings.Builder
fmt.Fprintf(&b, "# IM WebSocket 测试环境专项联调结果(2026-08-21)\n\n")
fmt.Fprintf(&b, "## 1. 测试范围\n\n")
fmt.Fprintf(&b, "- 测试时间:`%s`\n", time.Now().Format("2006-01-02 15:04:05 -0700"))
fmt.Fprintf(&b, "- HTTP:`%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.userA, a.usernameA, a.phoneA)
fmt.Fprintf(&b, "- 用户 B:`%s` / `%s` / `%s`\n", a.userB, a.usernameB, a.phoneB)
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
fmt.Fprintf(&b, "## 2. 总体结论\n\n")
fmt.Fprintf(&b, "本轮专项验证 MeetingIM WebSocket 真实协议链路,共执行 `%d` 项,通过 `%d` 项,未通过 `%d` 项。\n\n", total, passed, total-passed)
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 目标 | 结果 | 备注 |\n| --- | --- | --- | --- | --- | --- |\n")
for _, r := range a.results {
status := "通过"
if !r.Pass {
status = "未通过"
}
fmt.Fprintf(&b, "| `%s` | %s | `%s` | `%s` | %s | %s |\n", r.ID, esc(r.Name), r.Method, esc(r.Target), status, esc(r.Note))
}
fmt.Fprintf(&b, "\n## 3. 覆盖说明\n\n")
fmt.Fprintf(&b, "- 已覆盖:API Key Upgrade、`hi(tenant)`、`acc`、`basic login`、`token login`、重复登录拒绝、`sub me/fnd/p2p/grp`、`get desc/sub/data/del/tags/cred`、`set sub.private`、P2P/群 `pub`、产品 `x-im-*` 基础头、`note recv/read/kp`、`del msg`、`leave`、文件上传入口、文件消息、WebRTC invite、未 hi 顺序错误、切换租户拒绝、非法 API Key 拒绝。\n")
fmt.Fprintf(&b, "- 仍需前端/真机补充:断线重连恢复、Long Polling 长轮询持续收包、真实音视频媒体协商质量、离线 Push 到达与点击恢复。\n")
fmt.Fprintf(&b, "\n## 4. 测试产生的数据\n\n")
fmt.Fprintf(&b, "- P2P Topic:`%s`\n", a.p2pTopic)
fmt.Fprintf(&b, "- Bob 收到的 Topic:`%s`\n", a.bobTopic)
fmt.Fprintf(&b, "- 消息 Seq:`%s`\n", a.messageID)
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 asSlice(v any) []any {
items, _ := v.([]any)
return items
}
func mask(s string) string {
if len(s) <= 8 {
return s
}
return s[:4] + "..." + s[len(s)-4:]
}
func esc(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func compact(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 220 {
return s[:220] + "..."
}
return s
}