Files
nezha_domains/pkg/idcodec/idcodec_test.go
T
naibaandcloudcode d6f381d078 feat(idcodec): add hashid obfuscation derived from jwt secret
New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.

The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:50:36 +00:00

131 lines
2.9 KiB
Go

package idcodec
import (
"strings"
"sync"
"testing"
)
const testMasterKey = "this-is-a-32-byte-master-key-ok!"
func resetEncoder(t *testing.T) {
t.Helper()
mu.Lock()
encoder = nil
mu.Unlock()
}
func TestEncodeDecodeRoundTrip(t *testing.T) {
resetEncoder(t)
if err := Init([]byte(testMasterKey)); err != nil {
t.Fatalf("Init: %v", err)
}
cases := []uint64{1, 2, 42, 1_000_000, 1<<63 - 1}
for _, id := range cases {
code, err := Encode(id)
if err != nil {
t.Fatalf("Encode(%d): %v", id, err)
}
if len(code) < minLength {
t.Fatalf("code %q shorter than min %d", code, minLength)
}
got, err := Decode(code)
if err != nil {
t.Fatalf("Decode(%q): %v", code, err)
}
if got != id {
t.Fatalf("round-trip mismatch: got %d, want %d", got, id)
}
}
}
func TestEncodeBeforeInit(t *testing.T) {
resetEncoder(t)
if _, err := Encode(1); err != ErrNotInitialized {
t.Fatalf("Encode without Init: want ErrNotInitialized, got %v", err)
}
if _, err := Decode("abcdefgh"); err != ErrNotInitialized {
t.Fatalf("Decode without Init: want ErrNotInitialized, got %v", err)
}
}
func TestInitRejectsShortMasterKey(t *testing.T) {
resetEncoder(t)
if err := Init([]byte("too-short")); err != ErrMasterKeyShort {
t.Fatalf("Init short master key: want ErrMasterKeyShort, got %v", err)
}
}
func TestDecodeInvalidInputs(t *testing.T) {
resetEncoder(t)
if err := Init([]byte(testMasterKey)); err != nil {
t.Fatalf("Init: %v", err)
}
for _, code := range []string{"", "@@@@", strings.Repeat("!", 16)} {
if _, err := Decode(code); err == nil {
t.Fatalf("Decode(%q) must fail", code)
}
}
}
func TestAlphabetChangesWithMasterKey(t *testing.T) {
resetEncoder(t)
if err := Init([]byte(testMasterKey)); err != nil {
t.Fatalf("Init A: %v", err)
}
codeA, err := Encode(42)
if err != nil {
t.Fatalf("Encode A: %v", err)
}
resetEncoder(t)
if err := Init([]byte(testMasterKey + "rotated-suffix-makes-key-longer!")); err != nil {
t.Fatalf("Init B: %v", err)
}
codeB, err := Encode(42)
if err != nil {
t.Fatalf("Encode B: %v", err)
}
if codeA == codeB {
t.Fatalf("rotating master key must change hashid encoding for the same id; both produced %q", codeA)
}
if _, err := Decode(codeA); err == nil {
t.Fatalf("after rotation, old hashid %q must not decode under new key", codeA)
}
}
func TestConcurrentEncodeDecodeIsSafe(t *testing.T) {
resetEncoder(t)
if err := Init([]byte(testMasterKey)); err != nil {
t.Fatalf("Init: %v", err)
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(seed uint64) {
defer wg.Done()
for j := uint64(0); j < 1000; j++ {
id := seed*1000 + j
code, err := Encode(id)
if err != nil {
t.Errorf("Encode(%d): %v", id, err)
return
}
got, err := Decode(code)
if err != nil {
t.Errorf("Decode(%q): %v", code, err)
return
}
if got != id {
t.Errorf("round-trip: got %d, want %d", got, id)
return
}
}
}(uint64(i))
}
wg.Wait()
}