mirror of
https://github.com/jimeh/rands.git
synced 2026-02-19 03:16:39 +00:00
The UUID v7 format is a time-ordered random UUID. It uses a timestamp with millisecond precision in the most significant bits, followed by random data. This provides both uniqueness and chronological ordering, making it ideal for database primary keys and situations where sorting by creation time is desired. References: - https://uuid7.com/ - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7
620 lines
14 KiB
Go
620 lines
14 KiB
Go
package randsmust
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jimeh/rands"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestHex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "0123456789abcdef"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Hex(tt.n)
|
|
|
|
assert.Len(t, got, tt.n*2)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBase64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
|
"0123456789+/="
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Base64(tt.n)
|
|
|
|
b, err := base64.StdEncoding.DecodeString(got)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, b, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBase64URL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
|
"0123456789-_"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Base64URL(tt.n)
|
|
|
|
b, err := base64.RawURLEncoding.DecodeString(got)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, b, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlphanumeric(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Alphanumeric(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlphabetic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Alphabetic(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNumeric(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "0123456789"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Numeric(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Upper(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpperNumeric(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := UpperNumeric(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLower(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "abcdefghijklmnopqrstuvwxyz"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Lower(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLowerNumeric(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := LowerNumeric(tt.n)
|
|
|
|
assert.Len(t, got, tt.n)
|
|
assertAllowedChars(t, allowed, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
var stringTestCases = []struct {
|
|
name string
|
|
n int
|
|
alphabet string
|
|
panicErrIs error
|
|
panicStr string
|
|
}{
|
|
{
|
|
name: "greek",
|
|
n: 32,
|
|
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
|
|
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
|
|
panicErrIs: rands.ErrNonASCIIAlphabet,
|
|
panicStr: "rands: alphabet contains non-ASCII characters",
|
|
},
|
|
{
|
|
name: "chinese",
|
|
n: 32,
|
|
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
|
|
"时道出而要于就下得可你年生",
|
|
panicErrIs: rands.ErrNonASCIIAlphabet,
|
|
panicStr: "rands: alphabet contains non-ASCII characters",
|
|
},
|
|
{
|
|
name: "japanese",
|
|
n: 32,
|
|
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
|
|
"手文日月木水火犬王正出本右四",
|
|
panicErrIs: rands.ErrNonASCIIAlphabet,
|
|
panicStr: "rands: alphabet contains non-ASCII characters",
|
|
},
|
|
{
|
|
name: "n=0",
|
|
n: 0,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=1",
|
|
n: 1,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=2",
|
|
n: 2,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=7",
|
|
n: 7,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=8",
|
|
n: 8,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=16",
|
|
n: 16,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=32",
|
|
n: 32,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=128",
|
|
n: 128,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=1024",
|
|
n: 1024,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=409600",
|
|
n: 409600,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "n=2394345",
|
|
n: 2394345,
|
|
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
},
|
|
{
|
|
name: "uppercase",
|
|
n: 16,
|
|
alphabet: "ABCDEFGHJKMNPRSTUVWXYZ",
|
|
},
|
|
{
|
|
name: "lowercase",
|
|
n: 16,
|
|
alphabet: "abcdefghjkmnprstuvwxyz",
|
|
},
|
|
}
|
|
|
|
func TestString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tt := range stringTestCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got string
|
|
p := recoverPanic(func() {
|
|
got = String(tt.n, tt.alphabet)
|
|
})
|
|
|
|
if tt.panicErrIs == nil || tt.panicStr == "" {
|
|
assert.Len(t, []rune(got), tt.n)
|
|
assertAllowedChars(t, tt.alphabet, got)
|
|
}
|
|
|
|
if tt.panicErrIs != nil {
|
|
assert.ErrorIs(t, p.(error), tt.panicErrIs)
|
|
}
|
|
|
|
if tt.panicStr != "" {
|
|
assert.EqualError(t, p.(error), tt.panicStr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var unicodeStringTestCases = []struct {
|
|
name string
|
|
n int
|
|
alphabet string
|
|
}{
|
|
{
|
|
name: "n=0",
|
|
n: 0,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=1",
|
|
n: 1,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=2",
|
|
n: 2,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=7",
|
|
n: 7,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=8",
|
|
n: 8,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=16",
|
|
n: 16,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=32",
|
|
n: 32,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=128",
|
|
n: 128,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=1024",
|
|
n: 1024,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=409600",
|
|
n: 409600,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "n=2394345",
|
|
n: 2394345,
|
|
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
|
|
"手文日月木水火犬王正出本右",
|
|
},
|
|
{
|
|
name: "latin",
|
|
n: 32,
|
|
alphabet: "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnprstuvwxyz",
|
|
},
|
|
{
|
|
name: "greek",
|
|
n: 32,
|
|
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
|
|
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
|
|
},
|
|
{
|
|
name: "chinese",
|
|
n: 32,
|
|
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
|
|
"时道出而要于就下得可你年生",
|
|
},
|
|
{
|
|
name: "japanese",
|
|
n: 32,
|
|
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
|
|
"手文日月木水火犬王正出本右四",
|
|
},
|
|
}
|
|
|
|
func TestUnicodeString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tt := range unicodeStringTestCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := UnicodeString(tt.n, []rune(tt.alphabet))
|
|
|
|
assert.Len(t, []rune(got), tt.n)
|
|
assertAllowedChars(t, tt.alphabet, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
var dnsLabelTestCases = []struct {
|
|
name string
|
|
n int
|
|
panicErrIs error
|
|
panicStr string
|
|
}{
|
|
{
|
|
name: "n=-128",
|
|
n: -128,
|
|
panicErrIs: rands.ErrDNSLabelLength,
|
|
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
|
|
"in length",
|
|
},
|
|
{
|
|
name: "n=0",
|
|
n: 0,
|
|
panicErrIs: rands.ErrDNSLabelLength,
|
|
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
|
|
"in length",
|
|
},
|
|
{name: "n=1", n: 1},
|
|
{name: "n=2", n: 2},
|
|
{name: "n=3", n: 3},
|
|
{name: "n=4", n: 4},
|
|
{name: "n=5", n: 5},
|
|
{name: "n=6", n: 6},
|
|
{name: "n=7", n: 7},
|
|
{name: "n=8", n: 8},
|
|
{name: "n=16", n: 16},
|
|
{name: "n=32", n: 32},
|
|
{name: "n=63", n: 63},
|
|
{
|
|
name: "n=64",
|
|
n: 64,
|
|
panicErrIs: rands.ErrDNSLabelLength,
|
|
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
|
|
"in length",
|
|
},
|
|
{
|
|
name: "n=128",
|
|
n: 128,
|
|
panicErrIs: rands.ErrDNSLabelLength,
|
|
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
|
|
"in length",
|
|
},
|
|
}
|
|
|
|
func TestDNSLabel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tt := range dnsLabelTestCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// generate lots of labels to increase the chances of catching any
|
|
// obscure bugs
|
|
for i := 0; i < 10000; i++ {
|
|
var got string
|
|
p := recoverPanic(func() {
|
|
got = DNSLabel(tt.n)
|
|
})
|
|
|
|
if tt.panicErrIs == nil || tt.panicStr == "" {
|
|
require.Len(t, got, tt.n)
|
|
asserDNSLabel(t, got)
|
|
}
|
|
|
|
if tt.panicErrIs != nil {
|
|
require.ErrorIs(t, p.(error), tt.panicErrIs)
|
|
}
|
|
|
|
if tt.panicStr != "" {
|
|
require.EqualError(t, p.(error), tt.panicStr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUUID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := regexp.MustCompile(
|
|
`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`,
|
|
)
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
for i := 0; i < 10000; i++ {
|
|
got := UUID()
|
|
require.Regexp(t, m, got)
|
|
|
|
if _, ok := seen[got]; ok {
|
|
require.FailNow(t, "duplicate UUID")
|
|
}
|
|
|
|
seen[got] = struct{}{}
|
|
|
|
raw := strings.ReplaceAll(got, "-", "")
|
|
b := make([]byte, 16)
|
|
_, err := hex.Decode(b, []byte(raw))
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, 4, int(b[6]>>4), "version is not 4")
|
|
require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
|
|
}
|
|
}
|
|
|
|
func TestUUIDv7(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := regexp.MustCompile(
|
|
`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`,
|
|
)
|
|
|
|
// Store timestamps to verify they're increasing
|
|
var lastTimestampBytes int64
|
|
var lastUUID string
|
|
|
|
seen := make(map[string]struct{})
|
|
for i := 0; i < 10000; i++ {
|
|
got := UUIDv7()
|
|
require.Regexp(t, m, got)
|
|
|
|
if _, ok := seen[got]; ok {
|
|
require.FailNow(t, "duplicate UUID")
|
|
}
|
|
|
|
seen[got] = struct{}{}
|
|
|
|
raw := strings.ReplaceAll(got, "-", "")
|
|
b := make([]byte, 16)
|
|
_, err := hex.Decode(b, []byte(raw))
|
|
require.NoError(t, err)
|
|
|
|
// Check version is 7
|
|
require.Equal(t, 7, int(b[6]>>4), "version is not 7")
|
|
|
|
// Check variant is RFC 4122
|
|
require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
|
|
|
|
// Extract timestamp bytes
|
|
timestampBytes := int64(b[0])<<40 | int64(b[1])<<32 | int64(b[2])<<24 |
|
|
int64(b[3])<<16 | int64(b[4])<<8 | int64(b[5])
|
|
|
|
// Verify timestamp is within 100 milliseconds of current time
|
|
tsTime := time.UnixMilli(timestampBytes)
|
|
require.WithinDuration(t, time.Now(), tsTime, 100*time.Millisecond,
|
|
"timestamp is not within 100 milliseconds of current time")
|
|
|
|
// After the first UUID, verify that UUIDs are monotonically increasing
|
|
if i > 0 && timestampBytes < lastTimestampBytes {
|
|
require.FailNow(t, "UUIDs are not monotonically increasing",
|
|
"current: %s (ts: %d), previous: %s (ts: %d)",
|
|
got, timestampBytes, lastUUID, lastTimestampBytes)
|
|
}
|
|
|
|
lastTimestampBytes = timestampBytes
|
|
lastUUID = got
|
|
}
|
|
}
|
|
|
|
//
|
|
// Helpers
|
|
//
|
|
|
|
var (
|
|
dnsLabelHeadRx = regexp.MustCompile(`^[a-z]$`)
|
|
dnsLabelBodyRx = regexp.MustCompile(`^[a-z0-9-]+$`)
|
|
dnsLabelTailRx = regexp.MustCompile(`^[a-z0-9]$`)
|
|
)
|
|
|
|
func asserDNSLabel(t *testing.T, label string) {
|
|
require.LessOrEqualf(t, len(label), 63,
|
|
`DNS label "%s" is longer than 63 characters`, label,
|
|
)
|
|
|
|
require.GreaterOrEqualf(t, len(label), 1,
|
|
`DNS label "%s" is shorter than 1 character`, label,
|
|
)
|
|
|
|
if len(label) >= 1 {
|
|
require.Regexpf(t, dnsLabelHeadRx, string(label[0]),
|
|
`DNS label "%s" must start with a-z`, label,
|
|
)
|
|
}
|
|
if len(label) >= 2 {
|
|
require.Regexpf(t, dnsLabelTailRx, string(label[len(label)-1]),
|
|
`DNS label "%s" must end with a-z0-9`, label,
|
|
)
|
|
}
|
|
if len(label) >= 3 {
|
|
require.Regexpf(t, dnsLabelBodyRx, label[1:len(label)-1],
|
|
`DNS label "%s" body must only contain a-z0-9-`, label)
|
|
}
|
|
if len(label) >= 4 {
|
|
require.NotEqualf(t, "--", label[2:4],
|
|
`DNS label "%s" cannot contain "--" as 3rd and 4th char`, label,
|
|
)
|
|
}
|
|
}
|
|
|
|
func assertAllowedChars(t *testing.T, allowed string, s string) {
|
|
invalid := ""
|
|
for _, c := range s {
|
|
if !strings.Contains(allowed, string(c)) &&
|
|
!strings.Contains(invalid, string(c)) {
|
|
invalid += string(c)
|
|
}
|
|
}
|
|
|
|
assert.Truef(
|
|
t, len(invalid) == 0, "string contains invalid chars: %s", invalid,
|
|
)
|
|
}
|