You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

312 lines
11 KiB

package idverify
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// 创蓝把「不确定/失败」也放进 data.result,且此时 code 仍是成功码 000000。
// 这组用例守住最要命的一条:03/04 必须是 error,不能退化成「不一致」——
// 否则上游库没覆盖到的用户会被告知"您填的信息有误"。
func TestParseChuanglanBody(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
wantMatched bool
wantBizCode string
}{
{
// 文档「请求成功-结果示例」原样照抄。成功码是 000000(不是老版的 200000)。
name: "一致(文档原样示例)",
body: `{"msg":"success","chargeCount":1,"code":"000000","data":{"result":"01","birthday":"19930404","country":"金溪县","orderNo":"YQis1222128184616292352","handleTime":"2026-06-25 10:21:05","province":"江西省","gender":"1","city":"抚州地区","remark":"一致","age":"34"},"requestId":"YQis1222128184616292352","chargeStatus":1}`,
wantMatched: true,
wantBizCode: "01",
},
{
// 注意 result=02(不一致)时 code 仍是 000000、chargeStatus 仍是 1
// —— 调用成功 ≠ 核验一致,这两层结论必须分开读。
name: "不一致",
body: `{"msg":"success","chargeCount":1,"code":"000000","data":{"orderNo":"N2","result":"02","remark":"不一致"},"requestId":"R2","chargeStatus":1}`,
wantMatched: false,
wantBizCode: "02",
},
{
name: "认证不确定必须报错而不是判不一致",
body: `{"msg":"success","code":"000000","data":{"orderNo":"N3","result":"03","remark":"认证不确定"},"requestId":"R3","chargeStatus":0}`,
wantErr: true,
},
{
name: "认证失败必须报错而不是判不一致",
body: `{"msg":"success","code":"000000","data":{"orderNo":"N4","result":"04"},"requestId":"R4","chargeStatus":0}`,
wantErr: true,
},
{
// 文档「请求失败-结果示例」原样照抄:没有 data 对象
name: "参数校验异常(文档原样示例,无 data)",
body: `{"msg":"invalid parameter:校验异常: 身份证格式不正确","code":"190004","requestId":"YQis1222128941818187776","chargeStatus":0}`,
wantErr: true,
},
{
// 老版的成功码,OM2.0 下必须**不**被当成成功
name: "老版成功码 200000 不再视为成功",
body: `{"code":"200000","msg":"成功","data":{"result":"01"}}`,
wantErr: true,
},
{
name: "未知 result 按结论未知处理",
body: `{"code":"000000","msg":"success","data":{"result":"09"}}`,
wantErr: true,
},
{
name: "响应不是 JSON",
body: `<html>502 Bad Gateway</html>`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseChuanglanBody([]byte(tt.body))
if tt.wantErr {
if err == nil {
t.Fatalf("期望报错,实际得到 %+v", got)
}
// 报错时绝不能顺带给出一个「不一致」的结论
if got.Matched {
t.Fatalf("报错时 Matched 必须为 false,实际 %+v", got)
}
return
}
if err != nil {
t.Fatalf("不该报错: %v", err)
}
if got.Matched != tt.wantMatched {
t.Errorf("Matched = %v, 期望 %v", got.Matched, tt.wantMatched)
}
if got.BizCode != tt.wantBizCode {
t.Errorf("BizCode = %q, 期望 %q", got.BizCode, tt.wantBizCode)
}
})
}
}
// 起一个假服务端,端到端校验 OM2.0 的请求形态。
//
// 这几项是最容易写错又最难从线上现象反推的:
// 鉴权走**请求头**(AppID/Nonce/CurTime/CheckSum)而不是 body,
// body 是 JSON 且**只有** name/idNum——把凭据也塞进 body 是老版接口的写法。
func TestChuanglanRequestWireFormat(t *testing.T) {
const (
appID = "AID12345"
appSecret = "s3cr3t"
)
var (
gotMethod string
gotHeaders http.Header
gotBody map[string]any
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotHeaders = r.Header.Clone()
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
_, _ = io.WriteString(w, `{"code":"000000","msg":"success","chargeStatus":1,"chargeCount":1,"requestId":"R1","data":{"orderNo":"N1","result":"01","remark":"一致"}}`)
}))
defer srv.Close()
v, err := New(ProviderChuanglan, map[string]string{
"appid": appID, "appkey": appSecret, "url": srv.URL,
})
if err != nil {
t.Fatalf("装配失败: %v", err)
}
res, err := v.Verify(context.Background(), "廖江龙", "430422199001138812")
if err != nil {
t.Fatalf("Verify 失败: %v", err)
}
if !res.Matched {
t.Errorf("期望 Matched=true,实际 %+v", res)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %s, 期望 POST", gotMethod)
}
if ct := gotHeaders.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, 期望 application/json", ct)
}
if got := gotHeaders.Get("AppID"); got != appID {
t.Errorf("AppID 头 = %q, 期望 %q", got, appID)
}
nonce, curTime := gotHeaders.Get("Nonce"), gotHeaders.Get("CurTime")
if nonce == "" {
t.Error("缺少 Nonce 头")
}
if len(nonce) > 128 {
t.Errorf("Nonce 长度 %d 超过文档上限 128", len(nonce))
}
ts, err := strconv.ParseInt(curTime, 10, 64)
if err != nil {
t.Errorf("CurTime = %q 不是秒级时间戳", curTime)
} else if d := time.Since(time.Unix(ts, 0)); d < 0 || d > time.Minute {
// 签名 5 分钟内有效,时间戳必须是「现在」——写成毫秒会直接超期
t.Errorf("CurTime 偏离当前时间 %v,疑似单位写错(应为秒)", d)
}
// 签名必须能被服务端用同样规则复算出来
if want := chuanglanCheckSum(appSecret, nonce, curTime); gotHeaders.Get("CheckSum") != want {
t.Errorf("CheckSum = %q, 期望 %q", gotHeaders.Get("CheckSum"), want)
}
if gotBody["name"] != "廖江龙" || gotBody["idNum"] != "430422199001138812" {
t.Errorf("body = %+v, 期望只含 name/idNum", gotBody)
}
// 凭据绝不能出现在 body 里(老版接口才那么传)
for _, k := range []string{"appId", "appKey", "appid", "appkey", "AppID", "AppSecret"} {
if _, ok := gotBody[k]; ok {
t.Errorf("body 里不该出现凭据字段 %q", k)
}
}
}
// CheckSum = SHA1(AppSecret + Nonce + CurTime),十六进制**小写**。
// 拼接顺序错、或输出成大写,服务端一律回 120001,且报错里看不出是哪种。
func TestChuanglanCheckSum(t *testing.T) {
// 对照:printf 'secretnonce123' | shasum -a 1
const (
secret = "secret"
nonce = "nonce"
curTime = "123"
want = "4355af677cad2d478986b25d8fab707b41fbcee5"
)
got := chuanglanCheckSum(secret, nonce, curTime)
if got != want {
t.Errorf("CheckSum = %q, 期望 %q", got, want)
}
if got != strings.ToLower(got) {
t.Error("CheckSum 必须是小写十六进制")
}
if len(got) != 40 {
t.Errorf("SHA1 十六进制应为 40 字符,实际 %d", len(got))
}
}
// Nonce 每次都要新的:它参与签名,固定值会让签名可被重放。
func TestChuanglanNonceIsRandom(t *testing.T) {
seen := make(map[string]bool, 100)
for i := 0; i < 100; i++ {
n, err := chuanglanNonce()
if err != nil {
t.Fatalf("生成失败: %v", err)
}
if seen[n] {
t.Fatalf("Nonce 重复: %s", n)
}
seen[n] = true
}
}
// 默认地址必须是 OM2.0 那条带 /api/v2/ 的路径。
// 老版 /open/idcard/id-card-auth 已废弃,拿 OM2.0 的 AppID 去打会回
// 500902「用户不存在」——看着像凭据错,实则是接口版本用错了。
func TestChuanglanDefaultURL(t *testing.T) {
const want = "https://wsauth.253.com/api/v2/auth/idcard/id-card-auth"
if chuanglanDefaultURL != want {
t.Errorf("默认接口地址 = %q, 期望 %q", chuanglanDefaultURL, want)
}
}
// 缺凭据要在装配阶段就失败,不能等到打接口才发现。
func TestNewChuanglanRequiresCredentials(t *testing.T) {
if _, err := New(ProviderChuanglan, map[string]string{"appid": "x"}); err == nil {
t.Error("缺 appkey 时应报错")
}
if _, err := New(ProviderChuanglan, map[string]string{"appkey": "x"}); err == nil {
t.Error("缺 appid 时应报错")
}
v, err := New(ProviderChuanglan, map[string]string{"appid": "a", "appkey": "b"})
if err != nil {
t.Fatalf("凭据齐全不该报错: %v", err)
}
if v.Provider() != ProviderChuanglan {
t.Errorf("Provider() = %q, 期望 %q", v.Provider(), ProviderChuanglan)
}
// url 留空要回落到默认接口地址
cl, ok := v.(*chuanglanVerifier)
if !ok {
t.Fatalf("类型不对: %T", v)
}
if cl.url != chuanglanDefaultURL {
t.Errorf("url = %q, 期望默认 %q", cl.url, chuanglanDefaultURL)
}
}
// 鉴权头名的**字面大小写**必须与文档一致:AppID / Nonce / CurTime / CheckSum。
//
// HTTP 头名本该大小写无关,但创蓝网关是按字面匹配的。Go 的 Header.Set 会做 MIME
// 规范化,把 AppID→Appid、CurTime→Curtime、CheckSum→Checksum,网关就认不出来,
// 回 120301「request header is missing AppID or incorrect」——报错指向 AppID,
// 实际原因却是头名被改写,极难反推。所以必须直接写 Header map。
//
// 这里用裸 TCP 监听读原始请求字节:net/http 的 server 会把收到的头名规范化,
// 用 httptest 根本看不出线上真正发的是什么大小写。
func TestChuanglanAuthHeaderLiteralCase(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("监听失败: %v", err)
}
defer ln.Close()
const body = `{"code":"000000","msg":"success","chargeStatus":1,"data":{"result":"01"}}`
rawCh := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
rawCh <- ""
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
rawCh <- string(buf[:n])
fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body)
}()
v, err := New(ProviderChuanglan, map[string]string{
"appid": "AID", "appkey": "SEC", "url": "http://" + ln.Addr().String(),
})
if err != nil {
t.Fatalf("装配失败: %v", err)
}
_, _ = v.Verify(context.Background(), "廖江龙", "430422199001138812")
raw := <-rawCh
if raw == "" {
t.Fatal("没收到请求")
}
for _, want := range []string{"AppID:", "Nonce:", "CurTime:", "CheckSum:"} {
if !strings.Contains(raw, want) {
t.Errorf("原始请求里没有字面头名 %q——多半是误用了 Header.Set 导致大小写被规范化\n实际报文:\n%s", want, raw)
}
}
// 规范化后的形态一旦出现,说明改回 Header.Set 了
for _, bad := range []string{"Appid:", "Curtime:", "Checksum:"} {
if strings.Contains(raw, bad) {
t.Errorf("出现了被规范化的头名 %q,创蓝网关不认", bad)
}
}
}