package redis import ( "encoding/json" "errors" "math" "math/big" "sort" "testing" ) // 回归保护:HGETALL 在 RESP3 下返回 map[interface{}]interface{}(go-redis v9 默认 RESP3), // 这种类型 encoding/json 无法序列化,原值穿透到 Wails RPC 会让 Windows 进程退出(用户感知闪退)。 // formatCommandResult 必须把 map 平展成交错 [k1, v1, k2, v2, ...] array,前端按 array 渲染。 func TestFormatCommandResultFlattensRESP3MapForJSONMarshal(t *testing.T) { input := map[interface{}]interface{}{ "name": "alice", "age": "30", } got := formatCommandResult(input) arr, ok := got.([]interface{}) if !ok { t.Fatalf("expected flattened []interface{}, got %T (%#v)", got, got) } if len(arr) != 4 { t.Fatalf("expected 4 elements (2 pairs flattened), got %d: %#v", len(arr), arr) } // 平展后必须能被 encoding/json 序列化——这是修复的根本目的 if _, err := json.Marshal(arr); err != nil { t.Fatalf("flattened result must be JSON-marshalable, got error: %v", err) } // 验证 key+value 都保留下来了(顺序由 map 遍历决定,不强断言顺序) collected := make([]string, 0, 4) for _, item := range arr { s, ok := item.(string) if !ok { t.Fatalf("expected string element, got %T", item) } collected = append(collected, s) } sort.Strings(collected) want := []string{"30", "age", "alice", "name"} for i, w := range want { if collected[i] != w { t.Fatalf("flattened content mismatch at %d: got %q want %q (all=%v)", i, collected[i], w, collected) } } } // 嵌套 array 内含 map[interface{}]interface{} 也要递归平展, // 保证 XINFO STREAM 等返回嵌套结构的命令不会卡在序列化阶段。 func TestFormatCommandResultRecursivelyFlattensNestedMap(t *testing.T) { input := []interface{}{ "stream-id", map[interface{}]interface{}{"k": "v"}, } got := formatCommandResult(input) arr, ok := got.([]interface{}) if !ok || len(arr) != 2 { t.Fatalf("expected []interface{} of length 2, got %#v", got) } nested, ok := arr[1].([]interface{}) if !ok { t.Fatalf("expected nested map to be flattened to []interface{}, got %T", arr[1]) } if _, err := json.Marshal(arr); err != nil { t.Fatalf("recursively flattened result must be JSON-marshalable, got error: %v", err) } _ = nested } // 已经是 string-key 简单类型的命令(HGET、SET 之类)不应被改变。 func TestFormatCommandResultPreservesScalarAndByteSlice(t *testing.T) { if got := formatCommandResult("ok"); got != "ok" { t.Fatalf("string scalar should pass through, got %v", got) } if got := formatCommandResult([]byte("ok")); got != "ok" { t.Fatalf("[]byte should be converted to string, got %v", got) } if got := formatCommandResult(int64(42)); got != int64(42) { t.Fatalf("int64 scalar should pass through, got %v", got) } } func TestFormatCommandResultRecursivelyFormatsStringKeyMapValues(t *testing.T) { input := map[string]interface{}{ "nestedMap": map[interface{}]interface{}{"k": "v"}, "bytes": []byte("ok"), } got := formatCommandResult(input) formatted, ok := got.(map[string]interface{}) if !ok { t.Fatalf("expected map[string]interface{}, got %T (%#v)", got, got) } if formatted["bytes"] != "ok" { t.Fatalf("expected []byte value converted to string, got %#v", formatted["bytes"]) } if _, ok := formatted["nestedMap"].([]interface{}); !ok { t.Fatalf("expected nested RESP3 map to be flattened, got %T", formatted["nestedMap"]) } if _, err := json.Marshal(formatted); err != nil { t.Fatalf("formatted string-key map must be JSON-marshalable, got error: %v", err) } } func TestFormatCommandResultFormatsJSONUnsupportedScalars(t *testing.T) { input := []interface{}{ math.Inf(1), math.Inf(-1), math.NaN(), big.NewInt(1234567890123456789), errors.New("redis nested error"), } got := formatCommandResult(input) arr, ok := got.([]interface{}) if !ok || len(arr) != len(input) { t.Fatalf("expected formatted array of length %d, got %#v", len(input), got) } for i, item := range arr { if _, ok := item.(string); !ok { t.Fatalf("expected item %d to be string after formatting, got %T (%#v)", i, item, item) } } if _, err := json.Marshal(arr); err != nil { t.Fatalf("formatted unsupported scalars must be JSON-marshalable, got error: %v", err) } } func TestFormatCommandResultFormatsGenericMapsAndSlices(t *testing.T) { input := map[int][]byte{ 1: []byte("one"), } got := formatCommandResult(input) arr, ok := got.([]interface{}) if !ok || len(arr) != 2 { t.Fatalf("expected generic non-string map to be flattened into 2 elements, got %#v", got) } if _, err := json.Marshal(arr); err != nil { t.Fatalf("formatted generic map must be JSON-marshalable, got error: %v", err) } } func TestSanitizeRedisPassword(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "empty password", input: "", expected: "", }, { name: "plain password without special chars", input: "mypassword123", expected: "mypassword123", }, { name: "password with @ not encoded", input: "p@ssword", expected: "p@ssword", }, { name: "password with @ URL-encoded as %40", input: "p%40ssword", expected: "p@ssword", }, { name: "password with multiple encoded chars", input: "p%40ss%23word", expected: "p@ss#word", }, { name: "password with + encoded as %2B", input: "p%2Bss", expected: "p+ss", }, { name: "password that is purely encoded", input: "%40%23%24", expected: "@#$", }, { name: "password with invalid percent encoding", input: "p%ZZssword", expected: "p%ZZssword", }, { name: "password with trailing percent", input: "password%", expected: "password%", }, { name: "password with literal percent not encoding anything", input: "100%safe", expected: "100%safe", }, { name: "password with space encoded as %20", input: "my%20pass", expected: "my pass", }, { name: "complex password with mixed content", input: "P%40ss%23w0rd!", expected: "P@ss#w0rd!", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := sanitizeRedisPassword(tt.input) if result != tt.expected { t.Errorf("sanitizeRedisPassword(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestIsRedisKeyGone(t *testing.T) { tests := []struct { name string keyType string ttl int64 want bool }{ {name: "type none", keyType: "none", ttl: -2, want: true}, {name: "type none without ttl", keyType: "none", ttl: -1, want: true}, {name: "missing by ttl", keyType: "string", ttl: -2, want: true}, {name: "normal string", keyType: "string", ttl: 30, want: false}, {name: "permanent hash", keyType: "hash", ttl: -1, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want { t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want) } }) } } func TestNormalizeRedisGetValueError(t *testing.T) { err := normalizeRedisGetValueError("none", -2) if !errors.Is(err, ErrRedisKeyGone) { t.Fatalf("expected ErrRedisKeyGone, got %v", err) } if err == nil || err.Error() != "Redis Key 不存在或已过期" { t.Fatalf("unexpected error text: %v", err) } if normalizeRedisGetValueError("hash", -1) != nil { t.Fatal("expected nil for supported existing key") } } func TestRedisGlobPatternLiteralKey(t *testing.T) { tests := []struct { name string pattern string wantKey string wantExact bool }{ {name: "plain exact key", pattern: "Agent", wantKey: "Agent", wantExact: true}, {name: "escaped glob characters stay literal", pattern: `user:\*:\[id\]\?\\raw`, wantKey: `user:*:[id]?\raw`, wantExact: true}, {name: "fuzzy wildcard is not exact", pattern: "*[aA][gG][eE][nN][tT]*", wantExact: false}, {name: "unescaped suffix wildcard is not exact", pattern: "Agent*", wantExact: false}, {name: "unescaped single character wildcard is not exact", pattern: "Agent?", wantExact: false}, {name: "unescaped character class is not exact", pattern: "Agent[0-9]", wantExact: false}, {name: "empty pattern is not exact", pattern: "", wantExact: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotKey, gotExact := redisGlobPatternLiteralKey(tt.pattern) if gotExact != tt.wantExact { t.Fatalf("redisGlobPatternLiteralKey(%q) exact=%v, want %v", tt.pattern, gotExact, tt.wantExact) } if gotKey != tt.wantKey { t.Fatalf("redisGlobPatternLiteralKey(%q) key=%q, want %q", tt.pattern, gotKey, tt.wantKey) } }) } } func TestRedisExactSearchPattern(t *testing.T) { tests := []struct { name string literalKey string wantExactKey string wantNamespace string }{ { name: "plain namespace folder", literalKey: "Agent", wantExactKey: "Agent", wantNamespace: "Agent:*", }, { name: "escaped namespace keeps glob chars literal", literalKey: `user:*:[id]?\raw`, wantExactKey: `user:*:[id]?\raw`, wantNamespace: `user:\*:\[id\]\?\\raw:*`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotExactKey, gotNamespace := redisExactSearchPattern(tt.literalKey) if gotExactKey != tt.wantExactKey { t.Fatalf("redisExactSearchPattern(%q) exactKey=%q, want %q", tt.literalKey, gotExactKey, tt.wantExactKey) } if gotNamespace != tt.wantNamespace { t.Fatalf("redisExactSearchPattern(%q) namespace=%q, want %q", tt.literalKey, gotNamespace, tt.wantNamespace) } }) } } func TestReadRedisHashEntriesWithFallbackUsesHScanWhenHGetAllForbidden(t *testing.T) { scanCalls := 0 values, length, err := readRedisHashEntriesWithFallback( func() (map[string]string, error) { return nil, errors.New("ERR command 'HGETALL' not support for normal user") }, func() (int64, error) { return 2, nil }, func(cursor uint64, count int64) ([]string, uint64, error) { scanCalls++ if cursor != 0 { t.Fatalf("expected first scan cursor to be 0, got %d", cursor) } if count <= 0 { t.Fatalf("expected positive scan count, got %d", count) } return []string{"field-a", "value-a", "field-b", "value-b"}, 0, nil }, ) if err != nil { t.Fatalf("readRedisHashEntriesWithFallback() unexpected error: %v", err) } if scanCalls != 1 { t.Fatalf("expected exactly one HSCAN fallback, got %d", scanCalls) } if length != 2 { t.Fatalf("expected hash length 2, got %d", length) } if got := values["field-a"]; got != "value-a" { t.Fatalf("expected field-a=value-a, got %q", got) } if got := values["field-b"]; got != "value-b" { t.Fatalf("expected field-b=value-b, got %q", got) } } func TestReadRedisHashEntriesWithFallbackReturnsOriginalErrorForNonPermissionFailure(t *testing.T) { expectedErr := errors.New("ERR wrong type") _, _, err := readRedisHashEntriesWithFallback( func() (map[string]string, error) { return nil, expectedErr }, func() (int64, error) { t.Fatal("expected HLEN not to run for non-permission failure") return 0, nil }, func(cursor uint64, count int64) ([]string, uint64, error) { t.Fatal("expected HSCAN not to run for non-permission failure") return nil, 0, nil }, ) if !errors.Is(err, expectedErr) { t.Fatalf("expected original error %v, got %v", expectedErr, err) } }