|
@@ -0,0 +1,334 @@
|
|
|
|
|
+package helper
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "fwh-toolbox-server/domain"
|
|
|
|
|
+ "fwh-toolbox-server/dto"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 掩码函数单元测试 ----------
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskName(t *testing.T) {
|
|
|
|
|
+ cases := map[string]string{
|
|
|
|
|
+ "": "",
|
|
|
|
|
+ "张三": "张***",
|
|
|
|
|
+ "John": "J***",
|
|
|
|
|
+ "李": "李***",
|
|
|
|
|
+ }
|
|
|
|
|
+ for in, want := range cases {
|
|
|
|
|
+ if got := maskName(in); got != want {
|
|
|
|
|
+ t.Errorf("maskName(%q)=%q, want %q", in, got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskEmail(t *testing.T) {
|
|
|
|
|
+ if got := maskEmail("john@corp.com"); got != "j***@corp.com" {
|
|
|
|
|
+ t.Errorf("maskEmail got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 非法邮箱退化为完全脱敏
|
|
|
|
|
+ if got := maskEmail("notanemail"); got != maskFull() {
|
|
|
|
|
+ t.Errorf("maskEmail(invalid) got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := maskEmail(""); got != "" {
|
|
|
|
|
+ t.Errorf("maskEmail(empty) got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskAccount(t *testing.T) {
|
|
|
|
|
+ if got := maskAccount("abcdefgh"); got != "ab***gh" {
|
|
|
|
|
+ t.Errorf("maskAccount got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 过短完全脱敏
|
|
|
|
|
+ if got := maskAccount("abc"); got != maskFull() {
|
|
|
|
|
+ t.Errorf("maskAccount(short) got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskFullFixedLength(t *testing.T) {
|
|
|
|
|
+ if got := maskFull(); got != strings.Repeat(MaskChar, fullMaskLen) {
|
|
|
|
|
+ t.Errorf("maskFull got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 优化 B:流式 token 脱敏测试 ----------
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskJSONBytes_FlatFields(t *testing.T) {
|
|
|
|
|
+ in := []byte(`{
|
|
|
|
|
+ "sn": "SN123",
|
|
|
|
|
+ "userName": "Alice",
|
|
|
|
|
+ "contactNumber": "13812345678",
|
|
|
|
|
+ "email": "a@b.com",
|
|
|
|
|
+ "longitude": 121.5,
|
|
|
|
|
+ "latitude": 31.2,
|
|
|
|
|
+ "address": "123 Main St",
|
|
|
|
|
+ "city": "Shanghai",
|
|
|
|
|
+ "province": "SH",
|
|
|
|
|
+ "country": "CN",
|
|
|
|
|
+ "iccid": "8986012345678901234",
|
|
|
|
|
+ "imei": "356938035643809",
|
|
|
|
|
+ "account": "installer01"
|
|
|
|
|
+ }`)
|
|
|
|
|
+ out, err := MaskJSONBytes(in)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("MaskJSONBytes err: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var m map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(out, &m); err != nil {
|
|
|
|
|
+ t.Fatalf("output not valid JSON: %v\n%s", err, out)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保留字段
|
|
|
|
|
+ if m["sn"] != "SN123" {
|
|
|
|
|
+ t.Errorf("sn should be preserved, got %v", m["sn"])
|
|
|
|
|
+ }
|
|
|
|
|
+ if m["iccid"] != "8986012345678901234" {
|
|
|
|
|
+ t.Errorf("iccid should be preserved, got %v", m["iccid"])
|
|
|
|
|
+ }
|
|
|
|
|
+ if m["city"] != "Shanghai" || m["province"] != "SH" || m["country"] != "CN" {
|
|
|
|
|
+ t.Errorf("admin region should be preserved: city=%v province=%v country=%v", m["city"], m["province"], m["country"])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 完全脱敏字段
|
|
|
|
|
+ full := maskFull()
|
|
|
|
|
+ for _, k := range []string{"contactNumber", "longitude", "latitude", "address", "imei"} {
|
|
|
|
|
+ if m[k] != full {
|
|
|
|
|
+ t.Errorf("%s should be fully masked, got %v", k, m[k])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 姓名
|
|
|
|
|
+ if m["userName"] != "A***" {
|
|
|
|
|
+ t.Errorf("userName masked wrong: %v", m["userName"])
|
|
|
|
|
+ }
|
|
|
|
|
+ // 邮箱
|
|
|
|
|
+ if m["email"] != "a***@b.com" {
|
|
|
|
|
+ t.Errorf("email masked wrong: %v", m["email"])
|
|
|
|
|
+ }
|
|
|
|
|
+ // 账号
|
|
|
|
|
+ if m["account"] != "in***01" {
|
|
|
|
|
+ t.Errorf("account masked wrong: %v", m["account"])
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskJSONBytes_Nested(t *testing.T) {
|
|
|
|
|
+ in := []byte(`{
|
|
|
|
|
+ "data": [
|
|
|
|
|
+ {"userName": "Bob", "sn": "S1", "nested": {"phone": "111", "keep": "ok"}},
|
|
|
|
|
+ {"userName": "Carol", "sn": "S2"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 2
|
|
|
|
|
+ }`)
|
|
|
|
|
+ out, err := MaskJSONBytes(in)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("err: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var m map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(out, &m); err != nil {
|
|
|
|
|
+ t.Fatalf("invalid JSON: %v\n%s", err, out)
|
|
|
|
|
+ }
|
|
|
|
|
+ arr := m["data"].([]any)
|
|
|
|
|
+ first := arr[0].(map[string]any)
|
|
|
|
|
+ if first["userName"] != "B***" {
|
|
|
|
|
+ t.Errorf("nested list userName wrong: %v", first["userName"])
|
|
|
|
|
+ }
|
|
|
|
|
+ if first["sn"] != "S1" {
|
|
|
|
|
+ t.Errorf("sn should be preserved in list: %v", first["sn"])
|
|
|
|
|
+ }
|
|
|
|
|
+ nested := first["nested"].(map[string]any)
|
|
|
|
|
+ if nested["phone"] != maskFull() {
|
|
|
|
|
+ t.Errorf("deeply nested phone should be masked: %v", nested["phone"])
|
|
|
|
|
+ }
|
|
|
|
|
+ if nested["keep"] != "ok" {
|
|
|
|
|
+ t.Errorf("non-sensitive nested field should be preserved: %v", nested["keep"])
|
|
|
|
|
+ }
|
|
|
|
|
+ if m["total"].(float64) != 2 {
|
|
|
|
|
+ t.Errorf("total wrong: %v", m["total"])
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskJSONBytes_SensitiveSubtreeRedacted(t *testing.T) {
|
|
|
|
|
+ // 敏感 key 对应对象/数组时,整个子树被脱敏
|
|
|
|
|
+ in := []byte(`{"address": {"street": "secret", "no": 42}, "list_phone": ["a","b"]}`)
|
|
|
|
|
+ // 注意 list_phone 不在规则表,仅测 address 子树
|
|
|
|
|
+ out, err := MaskJSONBytes(in)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("err: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var m map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(out, &m); err != nil {
|
|
|
|
|
+ t.Fatalf("invalid JSON: %v\n%s", err, out)
|
|
|
|
|
+ }
|
|
|
|
|
+ addr := m["address"].(map[string]any)
|
|
|
|
|
+ if addr["street"] != maskFull() {
|
|
|
|
|
+ t.Errorf("address.street should be masked: %v", addr["street"])
|
|
|
|
|
+ }
|
|
|
|
|
+ // number 子字段被替换为掩码字符串
|
|
|
|
|
+ if addr["no"] != maskFull() {
|
|
|
|
|
+ t.Errorf("address.no should be masked: %v", addr["no"])
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskJSONBytes_InvalidJSONFailsClosed(t *testing.T) {
|
|
|
|
|
+ _, err := MaskJSONBytes([]byte(`{not valid json`))
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ t.Fatal("expected error for invalid JSON (fail-closed)")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestMaskJSONBytes_NullPreserved(t *testing.T) {
|
|
|
|
|
+ in := []byte(`{"userName": null, "phone": null}`)
|
|
|
|
|
+ out, err := MaskJSONBytes(in)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("err: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(string(out), `"userName":null`) {
|
|
|
|
|
+ t.Errorf("null should be preserved: %s", out)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 优化 A:强类型脱敏测试 ----------
|
|
|
|
|
+
|
|
|
|
|
+func TestSanitizeSiteInfoDTO_NoUpstreamPollution(t *testing.T) {
|
|
|
|
|
+ name := "Alice"
|
|
|
|
|
+ phone := "13812345678"
|
|
|
|
|
+ addr := "123 Main St"
|
|
|
|
|
+ iccid := "8986012345678901234"
|
|
|
|
|
+ orig := &dto.SiteInfoDTO{
|
|
|
|
|
+ Sn: "SN1",
|
|
|
|
|
+ UserName: &name,
|
|
|
|
|
+ ContactNumber: &phone,
|
|
|
|
|
+ Address: &addr,
|
|
|
|
|
+ Longitude: "121.5",
|
|
|
|
|
+ Latitude: "31.2",
|
|
|
|
|
+ City: "Shanghai",
|
|
|
|
|
+ ICCID: iccid,
|
|
|
|
|
+ IMEI: "356938035643809",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sanitized, ok := sanitizeStrong(orig)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Fatal("expected strong-type hit for *dto.SiteInfoDTO")
|
|
|
|
|
+ }
|
|
|
|
|
+ s := sanitized.(*dto.SiteInfoDTO)
|
|
|
|
|
+
|
|
|
|
|
+ // 脱敏结果正确
|
|
|
|
|
+ if *s.UserName != "A***" {
|
|
|
|
|
+ t.Errorf("userName: %v", *s.UserName)
|
|
|
|
|
+ }
|
|
|
|
|
+ if *s.ContactNumber != maskFull() {
|
|
|
|
|
+ t.Errorf("contactNumber: %v", *s.ContactNumber)
|
|
|
|
|
+ }
|
|
|
|
|
+ if *s.Address != maskFull() {
|
|
|
|
|
+ t.Errorf("address: %v", *s.Address)
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.Longitude != maskFull() || s.Latitude != maskFull() {
|
|
|
|
|
+ t.Errorf("geo not masked: %v %v", s.Longitude, s.Latitude)
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.IMEI != maskFull() {
|
|
|
|
|
+ t.Errorf("imei: %v", s.IMEI)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 保留字段
|
|
|
|
|
+ if s.ICCID != iccid {
|
|
|
|
|
+ t.Errorf("iccid should be preserved: %v", s.ICCID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.City != "Shanghai" {
|
|
|
|
|
+ t.Errorf("city should be preserved: %v", s.City)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 上游未被污染
|
|
|
|
|
+ if *orig.UserName != "Alice" || *orig.ContactNumber != "13812345678" || *orig.Address != "123 Main St" {
|
|
|
|
|
+ t.Errorf("upstream polluted: name=%v phone=%v addr=%v", *orig.UserName, *orig.ContactNumber, *orig.Address)
|
|
|
|
|
+ }
|
|
|
|
|
+ if orig.Longitude != "121.5" || orig.IMEI != "356938035643809" {
|
|
|
|
|
+ t.Errorf("upstream scalar polluted: lng=%v imei=%v", orig.Longitude, orig.IMEI)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSanitizeResPageSiteInfo(t *testing.T) {
|
|
|
|
|
+ name := "Bob"
|
|
|
|
|
+ orig := &dto.ResPage[dto.SiteInfoDTO]{
|
|
|
|
|
+ Data: []dto.SiteInfoDTO{{Sn: "S1", UserName: &name, IMEI: "111"}},
|
|
|
|
|
+ Total: 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ sanitized, ok := sanitizeStrong(orig)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Fatal("expected hit")
|
|
|
|
|
+ }
|
|
|
|
|
+ s := sanitized.(*dto.ResPage[dto.SiteInfoDTO])
|
|
|
|
|
+ if *s.Data[0].UserName != "B***" {
|
|
|
|
|
+ t.Errorf("page item userName: %v", *s.Data[0].UserName)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 上游 slice 未被污染
|
|
|
|
|
+ if *orig.Data[0].UserName != "Bob" {
|
|
|
|
|
+ t.Errorf("upstream page polluted: %v", *orig.Data[0].UserName)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSanitizeDomainSimCardInfo(t *testing.T) {
|
|
|
|
|
+ orig := &domain.SimCardInfo{GICCID: "GIC19", IMEI: "imei1", IMSI: "imsi1", MSISDN: "msisdn1"}
|
|
|
|
|
+ sanitized, ok := sanitizeStrong(orig)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Fatal("expected hit")
|
|
|
|
|
+ }
|
|
|
|
|
+ s := sanitized.(*domain.SimCardInfo)
|
|
|
|
|
+ if s.IMEI != maskFull() || s.IMSI != maskFull() || s.MSISDN != maskFull() {
|
|
|
|
|
+ t.Errorf("sim identifiers not masked: %+v", s)
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.GICCID != "GIC19" {
|
|
|
|
|
+ t.Errorf("giccid should be preserved: %v", s.GICCID)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 上游未污染
|
|
|
|
|
+ if orig.IMEI != "imei1" {
|
|
|
|
|
+ t.Errorf("upstream sim polluted: %v", orig.IMEI)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- A/B 一致性测试 ----------
|
|
|
|
|
+
|
|
|
|
|
+func TestAB_Consistency_SiteInfo(t *testing.T) {
|
|
|
|
|
+ name := "Alice"
|
|
|
|
|
+ phone := "13812345678"
|
|
|
|
|
+ addr := "123 Main St"
|
|
|
|
|
+ site := &dto.SiteInfoDTO{
|
|
|
|
|
+ Sn: "SN1",
|
|
|
|
|
+ UserName: &name,
|
|
|
|
|
+ ContactNumber: &phone,
|
|
|
|
|
+ Address: &addr,
|
|
|
|
|
+ Longitude: "121.5",
|
|
|
|
|
+ City: "Shanghai",
|
|
|
|
|
+ ICCID: "8986012345678901234",
|
|
|
|
|
+ IMEI: "356938035643809",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // A 路径
|
|
|
|
|
+ sanitized, _ := sanitizeStrong(site)
|
|
|
|
|
+ aBytes, err := json.Marshal(sanitized)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // B 路径:直接对原始结构体序列化后走流式脱敏
|
|
|
|
|
+ rawBytes, _ := json.Marshal(site)
|
|
|
|
|
+ bBytes, err := MaskJSONBytes(rawBytes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("B err: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 归一化比较(字段顺序可能不同,比较解析后的 map)
|
|
|
|
|
+ var ma, mb map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(aBytes, &ma); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(bBytes, &mb); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, k := range []string{"userName", "contactNumber", "address", "longitude", "imei", "iccId", "city", "sn"} {
|
|
|
|
|
+ if ma[k] != mb[k] {
|
|
|
|
|
+ t.Errorf("A/B mismatch on %q: A=%v B=%v", k, ma[k], mb[k])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|