Files
ids/id_test.go
zeekay 71111bfc6d fix(id): UnmarshalText accepts unquoted CB58, fixes map[ids.ID]V json.Unmarshal
UnmarshalText previously delegated to UnmarshalJSON, which required
quoted input. Go's encoding/json decodes map keys via the
TextUnmarshaler contract, which passes the UNQUOTED key. This made
json.Unmarshal of any map[ids.ID]V fail with errMissingQuotes —
visible to operators as luxd rejecting --chain-aliases-file and
--chain-aliases-file-content with 'first and last characters
should be quotes' even on well-formed input.

The fix is symmetry: MarshalText emits id.String() unquoted, so
UnmarshalText now parses the unquoted CB58/native string directly
(matching FromString). Quoted input continues to round-trip
through UnmarshalJSON, untouched.

Regression test covers both the unquoted text path and the actual
map[ID][]string json round-trip that motivated the fix.
2026-06-02 22:07:15 -07:00

385 lines
8.0 KiB
Go

// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ids
import (
"encoding/json"
"fmt"
"slices"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/cb58"
"github.com/luxfi/crypto/hash"
)
func TestID(t *testing.T) {
require := require.New(t)
id := ID{24}
idCopy := ID{24}
prefixed := id.Prefix(0)
require.Equal(idCopy, id)
require.Equal(prefixed, id.Prefix(0))
}
func TestIDPrefix(t *testing.T) {
id := GenerateTestID()
tests := []struct {
name string
id ID
prefix []uint64
expectedPreimage []byte
}{
{
name: "empty prefix",
id: id,
prefix: []uint64{},
expectedPreimage: id[:],
},
{
name: "1 prefix",
id: id,
prefix: []uint64{1},
expectedPreimage: slices.Concat(
[]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01},
id[:],
),
},
{
name: "multiple prefixes",
id: id,
prefix: []uint64{1, 256},
expectedPreimage: slices.Concat(
[]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01},
[]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00},
id[:],
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
expected := ID(hash.ComputeHash256Array(test.expectedPreimage))
require.Equal(t, expected, test.id.Prefix(test.prefix...))
})
}
}
func TestIDAppend(t *testing.T) {
id := GenerateTestID()
tests := []struct {
name string
id ID
suffix []uint32
expectedPreimage []byte
}{
{
name: "empty suffix",
id: id,
suffix: []uint32{},
expectedPreimage: id[:],
},
{
name: "1 suffix",
id: id,
suffix: []uint32{1},
expectedPreimage: slices.Concat(
id[:],
[]byte{0x00, 0x00, 0x00, 0x01},
),
},
{
name: "multiple suffixes",
id: id,
suffix: []uint32{1, 256},
expectedPreimage: slices.Concat(
id[:],
[]byte{0x00, 0x00, 0x00, 0x01},
[]byte{0x00, 0x00, 0x01, 0x00},
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
expected := ID(hash.ComputeHash256Array(test.expectedPreimage))
require.Equal(t, expected, test.id.Append(test.suffix...))
})
}
}
func TestIDXOR(t *testing.T) {
require := require.New(t)
id1 := ID{1}
id3 := ID{3}
require.Equal(ID{2}, id1.XOR(id3))
require.Equal(ID{1}, id1)
}
func TestIDBit(t *testing.T) {
require := require.New(t)
id0 := ID{1 << 0}
id1 := ID{1 << 1}
id2 := ID{1 << 2}
id3 := ID{1 << 3}
id4 := ID{1 << 4}
id5 := ID{1 << 5}
id6 := ID{1 << 6}
id7 := ID{1 << 7}
id8 := ID{0, 1 << 0}
require.Equal(1, id0.Bit(0))
require.Equal(1, id1.Bit(1))
require.Equal(1, id2.Bit(2))
require.Equal(1, id3.Bit(3))
require.Equal(1, id4.Bit(4))
require.Equal(1, id5.Bit(5))
require.Equal(1, id6.Bit(6))
require.Equal(1, id7.Bit(7))
require.Equal(1, id8.Bit(8))
}
func TestFromString(t *testing.T) {
require := require.New(t)
id := ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'}
idStr := id.String()
id2, err := FromString(idStr)
require.NoError(err)
require.Equal(id, id2)
}
func TestIDFromStringError(t *testing.T) {
tests := []struct {
in string
expectedErr error
}{
{
in: "",
expectedErr: cb58.ErrBase58Decoding,
},
{
in: "foo",
expectedErr: cb58.ErrMissingChecksum,
},
{
in: "foobar",
expectedErr: cb58.ErrBadChecksum,
},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
_, err := FromString(tt.in)
require.ErrorIs(t, err, tt.expectedErr)
})
}
}
func TestIDMarshalJSON(t *testing.T) {
tests := []struct {
label string
in ID
out []byte
err error
}{
{
"ID{}",
ID{},
[]byte(`"11111111111111111111111111111111LpoYY"`),
nil,
},
{
`ID("ava labs")`,
ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
[]byte(`"jvYi6Tn9idMi7BaymUVi9zWjg5tpmW7trfKG1AYJLKZJ2fsU7"`),
nil,
},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
require := require.New(t)
out, err := tt.in.MarshalJSON()
require.ErrorIs(err, tt.err)
require.Equal(tt.out, out)
})
}
}
func TestIDUnmarshalJSON(t *testing.T) {
tests := []struct {
label string
in []byte
out ID
err error
}{
{
"ID{}",
[]byte("null"),
ID{},
nil,
},
{
`ID("ava labs")`,
[]byte(`"jvYi6Tn9idMi7BaymUVi9zWjg5tpmW7trfKG1AYJLKZJ2fsU7"`),
ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
nil,
},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
require := require.New(t)
foo := ID{}
err := foo.UnmarshalJSON(tt.in)
require.ErrorIs(err, tt.err)
require.Equal(tt.out, foo)
})
}
}
func TestIDHex(t *testing.T) {
id := ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'}
expected := "617661206c616273000000000000000000000000000000000000000000000000"
require.Equal(t, expected, id.Hex())
}
// TestIDUnmarshalText covers the unquoted CB58 path that
// encoding/json invokes for map keys. Regression: the previous
// implementation delegated to UnmarshalJSON and rejected unquoted input
// with errMissingQuotes, so json.Unmarshal of any map[ids.ID]V failed
// even for byte-identical content that worked in struct fields.
func TestIDUnmarshalText(t *testing.T) {
tests := []struct {
label string
in string
out ID
}{
{
"empty -> Empty",
"",
Empty,
},
{
"null -> Empty",
"null",
Empty,
},
{
"unquoted CB58",
"jvYi6Tn9idMi7BaymUVi9zWjg5tpmW7trfKG1AYJLKZJ2fsU7",
ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
require := require.New(t)
var got ID
require.NoError(got.UnmarshalText([]byte(tt.in)))
require.Equal(tt.out, got)
})
}
}
// TestIDMapKeyJSONRoundTrip is the actual production scenario:
// map[ids.ID][]string used by luxd's chain-aliases parser. Both
// directions must succeed against the same Go encoding/json runtime.
func TestIDMapKeyJSONRoundTrip(t *testing.T) {
require := require.New(t)
id := ID{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'}
in := map[ID][]string{id: {"hanzo", "h"}}
enc, err := json.Marshal(in)
require.NoError(err)
var out map[ID][]string
require.NoError(json.Unmarshal(enc, &out))
require.Equal(in, out)
}
func TestIDString(t *testing.T) {
tests := []struct {
label string
id ID
expected string
}{
{"ID{}", ID{}, "11111111111111111111111111111111LpoYY"},
{"ID{24}", ID{24}, "Ba3mm8Ra8JYYebeZ9p7zw1ayorDbeD1euwxhgzSLsncKqGoNt"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
require.Equal(t, tt.expected, tt.id.String())
})
}
}
func TestSortIDs(t *testing.T) {
ids := []ID{
{'e', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
{'W', 'a', 'l', 'l', 'e', ' ', 'l', 'a', 'b', 's'},
{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
}
slices.SortFunc(ids, func(a, b ID) int {
return a.Compare(b)
})
expected := []ID{
{'W', 'a', 'l', 'l', 'e', ' ', 'l', 'a', 'b', 's'},
{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
{'e', 'v', 'a', ' ', 'l', 'a', 'b', 's'},
}
require.Equal(t, expected, ids)
}
func TestIDMapMarshalling(t *testing.T) {
require := require.New(t)
originalMap := map[ID]int{
{'e', 'v', 'a', ' ', 'l', 'a', 'b', 's'}: 1,
{'a', 'v', 'a', ' ', 'l', 'a', 'b', 's'}: 2,
}
mapJSON, err := json.Marshal(originalMap)
require.NoError(err)
var unmarshalledMap map[ID]int
require.NoError(json.Unmarshal(mapJSON, &unmarshalledMap))
require.Equal(originalMap, unmarshalledMap)
}
func TestIDCompare(t *testing.T) {
tests := []struct {
a ID
b ID
expected int
}{
{
a: ID{1},
b: ID{0},
expected: 1,
},
{
a: ID{1},
b: ID{1},
expected: 0,
},
{
a: ID{1, 0},
b: ID{1, 2},
expected: -1,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%s_%s_%d", test.a, test.b, test.expected), func(t *testing.T) {
require := require.New(t)
require.Equal(test.expected, test.a.Compare(test.b))
require.Equal(-test.expected, test.b.Compare(test.a))
})
}
}