mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-04 21:49:36 +08:00
- 根因:go-redis v9 默认 RESP3 协议,HGETALL 返回 map[interface{}]interface{},encoding/json 不支持非 string key 的 map,原值穿透到 Wails RPC 导致 Windows 进程退出
- formatCommandResult 新增 map[interface{}]interface{} 分支,平展为交错 [k1, v1, k2, v2, ...] array,与 RESP2 输出形式一致,前端 array 渲染零改动
- 递归调用确保 XINFO STREAM 等嵌套 RESP3 map 也被平展
- 在函数 doc comment 固化"为什么这样做"防止后人删除
- 新增 3 个单元测试:扁平化 + JSON 可序列化、嵌套 map 递归处理、scalar 与 []byte 不被改变
320 lines
9.4 KiB
Go
320 lines
9.4 KiB
Go
package redis
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"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 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)
|
||
}
|
||
}
|