833 lines
20 KiB
Go
833 lines
20 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
||
// SPDX-License-Identifier: MPL-2.0
|
||
|
||
package random
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
MRAND "math/rand"
|
||
"reflect"
|
||
"sort"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestStringGenerator_Generate_successful(t *testing.T) {
|
||
type testCase struct {
|
||
timeout time.Duration
|
||
generator *StringGenerator
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"common rules": {
|
||
timeout: 1 * time.Second,
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: LowercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: UppercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: NumericRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: ShortSymbolRuneset,
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
},
|
||
},
|
||
"charset not explicitly specified": {
|
||
timeout: 1 * time.Second,
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: LowercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: UppercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: NumericRuneset,
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
// One context to rule them all, one context to find them, one context to bring them all and in the darkness bind them.
|
||
ctx, cancel := context.WithTimeout(context.Background(), test.timeout)
|
||
defer cancel()
|
||
|
||
runeset := map[rune]bool{}
|
||
runesFound := []rune{}
|
||
|
||
for i := 0; i < 100; i++ {
|
||
actual, err := test.generator.Generate(ctx, nil)
|
||
if err != nil {
|
||
t.Fatalf("no error expected, but got: %s", err)
|
||
}
|
||
for _, r := range actual {
|
||
if runeset[r] {
|
||
continue
|
||
}
|
||
runeset[r] = true
|
||
runesFound = append(runesFound, r)
|
||
}
|
||
}
|
||
|
||
sort.Sort(runes(runesFound))
|
||
|
||
expectedCharset := getChars(test.generator.Rules)
|
||
|
||
if !reflect.DeepEqual(runesFound, expectedCharset) {
|
||
t.Fatalf("Didn't find all characters from the charset\nActual : [%s]\nExpected: [%s]", string(runesFound), string(expectedCharset))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestStringGenerator_Generate_errors(t *testing.T) {
|
||
type testCase struct {
|
||
timeout time.Duration
|
||
generator *StringGenerator
|
||
rng io.Reader
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"already timed out": {
|
||
timeout: 0,
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{
|
||
testCharsetRule{
|
||
fail: false,
|
||
},
|
||
},
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
},
|
||
rng: rand.Reader,
|
||
},
|
||
"impossible rules": {
|
||
timeout: 10 * time.Millisecond, // Keep this short so the test doesn't take too long
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{
|
||
testCharsetRule{
|
||
fail: true,
|
||
},
|
||
},
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
},
|
||
rng: rand.Reader,
|
||
},
|
||
"bad RNG reader": {
|
||
timeout: 10 * time.Millisecond, // Keep this short so the test doesn't take too long
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{},
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
},
|
||
rng: badReader{},
|
||
},
|
||
"0 length": {
|
||
timeout: 10 * time.Millisecond,
|
||
generator: &StringGenerator{
|
||
Length: 0,
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: []rune("abcde"),
|
||
MinChars: 0,
|
||
},
|
||
},
|
||
charset: []rune("abcde"),
|
||
},
|
||
rng: rand.Reader,
|
||
},
|
||
"-1 length": {
|
||
timeout: 10 * time.Millisecond,
|
||
generator: &StringGenerator{
|
||
Length: -1,
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: []rune("abcde"),
|
||
MinChars: 0,
|
||
},
|
||
},
|
||
charset: []rune("abcde"),
|
||
},
|
||
rng: rand.Reader,
|
||
},
|
||
"no charset": {
|
||
timeout: 10 * time.Millisecond,
|
||
generator: &StringGenerator{
|
||
Length: 20,
|
||
Rules: []Rule{},
|
||
},
|
||
rng: rand.Reader,
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
// One context to rule them all, one context to find them, one context to bring them all and in the darkness bind them.
|
||
ctx, cancel := context.WithTimeout(context.Background(), test.timeout)
|
||
defer cancel()
|
||
|
||
actual, err := test.generator.Generate(ctx, test.rng)
|
||
if err == nil {
|
||
t.Fatalf("Expected error but none found")
|
||
}
|
||
if actual != "" {
|
||
t.Fatalf("Random string returned: %s", actual)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRandomRunes_deterministic(t *testing.T) {
|
||
// These tests are to ensure that the charset selection doesn't do anything weird like selecting the same character
|
||
// over and over again. The number of test cases here should be kept to a minimum since they are sensitive to changes
|
||
type testCase struct {
|
||
rngSeed int64
|
||
charset string
|
||
length int
|
||
expected string
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"small charset": {
|
||
rngSeed: 1585593298447807000,
|
||
charset: "abcde",
|
||
length: 20,
|
||
expected: "ddddddcdebbeebdbdbcd",
|
||
},
|
||
"common charset": {
|
||
rngSeed: 1585593298447807001,
|
||
charset: AlphaNumericShortSymbolCharset,
|
||
length: 20,
|
||
expected: "ON6lVjnBs84zJbUBVEzb",
|
||
},
|
||
"max size charset": {
|
||
rngSeed: 1585593298447807002,
|
||
charset: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
|
||
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
|
||
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠",
|
||
length: 20,
|
||
expected: "tųŎ℄ņ℃Œ.@řHš-ℍ}ħGIJLℏ",
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
rng := MRAND.New(MRAND.NewSource(test.rngSeed))
|
||
runes, err := randomRunes(rng, []rune(test.charset), test.length)
|
||
if err != nil {
|
||
t.Fatalf("Expected no error, but found: %s", err)
|
||
}
|
||
|
||
str := string(runes)
|
||
|
||
if str != test.expected {
|
||
t.Fatalf("Actual: %s Expected: %s", str, test.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRandomRunes_successful(t *testing.T) {
|
||
type testCase struct {
|
||
charset []rune // Assumes no duplicate runes
|
||
length int
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"small charset": {
|
||
charset: []rune("abcde"),
|
||
length: 20,
|
||
},
|
||
"common charset": {
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
length: 20,
|
||
},
|
||
"max size charset": {
|
||
charset: []rune(
|
||
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
|
||
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
|
||
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠",
|
||
),
|
||
length: 20,
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
runeset := map[rune]bool{}
|
||
runesFound := []rune{}
|
||
|
||
for i := 0; i < 10000; i++ {
|
||
actual, err := randomRunes(rand.Reader, test.charset, test.length)
|
||
if err != nil {
|
||
t.Fatalf("no error expected, but got: %s", err)
|
||
}
|
||
for _, r := range actual {
|
||
if runeset[r] {
|
||
continue
|
||
}
|
||
runeset[r] = true
|
||
runesFound = append(runesFound, r)
|
||
}
|
||
}
|
||
|
||
sort.Sort(runes(runesFound))
|
||
|
||
// Sort the input too just to ensure that they can be compared
|
||
sort.Sort(runes(test.charset))
|
||
|
||
if !reflect.DeepEqual(runesFound, test.charset) {
|
||
t.Fatalf("Didn't find all characters from the charset\nActual : [%s]\nExpected: [%s]", string(runesFound), string(test.charset))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRandomRunes_errors(t *testing.T) {
|
||
type testCase struct {
|
||
charset []rune
|
||
length int
|
||
rng io.Reader
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"nil charset": {
|
||
charset: nil,
|
||
length: 20,
|
||
rng: rand.Reader,
|
||
},
|
||
"empty charset": {
|
||
charset: []rune{},
|
||
length: 20,
|
||
rng: rand.Reader,
|
||
},
|
||
"charset is too long": {
|
||
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
|
||
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
|
||
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠" +
|
||
"Σ",
|
||
),
|
||
length: 20,
|
||
rng: rand.Reader,
|
||
},
|
||
"length is zero": {
|
||
charset: []rune("abcde"),
|
||
length: 0,
|
||
rng: rand.Reader,
|
||
},
|
||
"length is negative": {
|
||
charset: []rune("abcde"),
|
||
length: -3,
|
||
rng: rand.Reader,
|
||
},
|
||
"reader failed": {
|
||
charset: []rune("abcde"),
|
||
length: 20,
|
||
rng: badReader{},
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
actual, err := randomRunes(test.rng, test.charset, test.length)
|
||
if err == nil {
|
||
t.Fatalf("Expected error but none found")
|
||
}
|
||
if actual != nil {
|
||
t.Fatalf("Expected no value, but found [%s]", string(actual))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func BenchmarkStringGenerator_Generate(b *testing.B) {
|
||
lengths := []int{
|
||
8, 12, 16, 20, 24, 28,
|
||
}
|
||
|
||
type testCase struct {
|
||
generator *StringGenerator
|
||
}
|
||
|
||
benches := map[string]testCase{
|
||
"no restrictions": {
|
||
generator: &StringGenerator{
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: AlphaNumericFullSymbolRuneset,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"default generator": {
|
||
generator: DefaultStringGenerator,
|
||
},
|
||
"large symbol set": {
|
||
generator: &StringGenerator{
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: LowercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: UppercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: NumericRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: FullSymbolRuneset,
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"max symbol set": {
|
||
generator: &StringGenerator{
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
|
||
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
|
||
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠"),
|
||
},
|
||
CharsetRule{
|
||
Charset: LowercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: UppercaseRuneset,
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: []rune("ĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒ"),
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"restrictive charset rules": {
|
||
generator: &StringGenerator{
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: AlphaNumericShortSymbolRuneset,
|
||
},
|
||
CharsetRule{
|
||
Charset: []rune("A"),
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: []rune("1"),
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: []rune("a"),
|
||
MinChars: 1,
|
||
},
|
||
CharsetRule{
|
||
Charset: []rune("-"),
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for name, bench := range benches {
|
||
b.Run(name, func(b *testing.B) {
|
||
for _, length := range lengths {
|
||
bench.generator.Length = length
|
||
b.Run(fmt.Sprintf("length=%d", length), func(b *testing.B) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
str, err := bench.generator.Generate(ctx, nil)
|
||
if err != nil {
|
||
b.Fatalf("Failed to generate string: %s", err)
|
||
}
|
||
if str == "" {
|
||
b.Fatalf("Didn't error but didn't generate a string")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// Mimic what the SQLCredentialsProducer is doing
|
||
b.Run("SQLCredentialsProducer", func(b *testing.B) {
|
||
sg := StringGenerator{
|
||
Length: 16, // 16 because the SQLCredentialsProducer prepends 4 characters to a 20 character password
|
||
charset: AlphaNumericRuneset,
|
||
Rules: nil,
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
str, err := sg.Generate(ctx, nil)
|
||
if err != nil {
|
||
b.Fatalf("Failed to generate string: %s", err)
|
||
}
|
||
if str == "" {
|
||
b.Fatalf("Didn't error but didn't generate a string")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// Ensure the StringGenerator can be properly JSON-ified
|
||
func TestStringGenerator_JSON(t *testing.T) {
|
||
expected := StringGenerator{
|
||
Length: 20,
|
||
charset: deduplicateRunes([]rune("teststring" + ShortSymbolCharset)),
|
||
Rules: []Rule{
|
||
testCharsetRule{
|
||
String: "teststring",
|
||
Integer: 123,
|
||
},
|
||
CharsetRule{
|
||
Charset: ShortSymbolRuneset,
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
}
|
||
|
||
b, err := json.Marshal(expected)
|
||
if err != nil {
|
||
t.Fatalf("Failed to marshal to JSON: %s", err)
|
||
}
|
||
|
||
parser := PolicyParser{
|
||
RuleRegistry: Registry{
|
||
Rules: map[string]ruleConstructor{
|
||
"testrule": newTestRule,
|
||
"charset": ParseCharset,
|
||
},
|
||
},
|
||
}
|
||
actual, err := parser.ParsePolicy(string(b))
|
||
if err != nil {
|
||
t.Fatalf("Failed to parse JSON: %s", err)
|
||
}
|
||
|
||
if !reflect.DeepEqual(actual, expected) {
|
||
t.Fatalf("Actual: %#v\nExpected: %#v", actual, expected)
|
||
}
|
||
}
|
||
|
||
type badReader struct{}
|
||
|
||
func (badReader) Read([]byte) (int, error) {
|
||
return 0, fmt.Errorf("test error")
|
||
}
|
||
|
||
func TestValidate(t *testing.T) {
|
||
type testCase struct {
|
||
generator *StringGenerator
|
||
expectErr bool
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"default generator": {
|
||
generator: DefaultStringGenerator,
|
||
expectErr: false,
|
||
},
|
||
"length is 0": {
|
||
generator: &StringGenerator{
|
||
Length: 0,
|
||
},
|
||
expectErr: true,
|
||
},
|
||
"length is negative": {
|
||
generator: &StringGenerator{
|
||
Length: -2,
|
||
},
|
||
expectErr: true,
|
||
},
|
||
"nil charset, no rules": {
|
||
generator: &StringGenerator{
|
||
Length: 5,
|
||
charset: nil,
|
||
},
|
||
expectErr: true,
|
||
},
|
||
"zero length charset, no rules": {
|
||
generator: &StringGenerator{
|
||
Length: 5,
|
||
charset: []rune{},
|
||
},
|
||
expectErr: true,
|
||
},
|
||
"rules require password longer than length": {
|
||
generator: &StringGenerator{
|
||
Length: 5,
|
||
charset: []rune("abcde"),
|
||
Rules: []Rule{
|
||
CharsetRule{
|
||
Charset: []rune("abcde"),
|
||
MinChars: 6,
|
||
},
|
||
},
|
||
},
|
||
expectErr: true,
|
||
},
|
||
"charset has non-printable characters": {
|
||
generator: &StringGenerator{
|
||
Length: 0,
|
||
charset: []rune{
|
||
'a',
|
||
'b',
|
||
0, // Null character
|
||
'd',
|
||
'e',
|
||
},
|
||
},
|
||
expectErr: true,
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
err := test.generator.validateConfig()
|
||
if test.expectErr && err == nil {
|
||
t.Fatalf("err expected, got nil")
|
||
}
|
||
if !test.expectErr && err != nil {
|
||
t.Fatalf("no error expected, got: %s", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
type testNonCharsetRule struct {
|
||
String string `mapstructure:"string" json:"string"`
|
||
}
|
||
|
||
func (tr testNonCharsetRule) Pass([]rune) bool { return true }
|
||
func (tr testNonCharsetRule) Type() string { return "testNonCharsetRule" }
|
||
|
||
func TestGetChars(t *testing.T) {
|
||
type testCase struct {
|
||
rules []Rule
|
||
expected []rune
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"nil rules": {
|
||
rules: nil,
|
||
expected: []rune(nil),
|
||
},
|
||
"empty rules": {
|
||
rules: []Rule{},
|
||
expected: []rune(nil),
|
||
},
|
||
"rule without chars": {
|
||
rules: []Rule{
|
||
testNonCharsetRule{
|
||
String: "teststring",
|
||
},
|
||
},
|
||
expected: []rune(nil),
|
||
},
|
||
"rule with chars": {
|
||
rules: []Rule{
|
||
CharsetRule{
|
||
Charset: []rune("abcdefghij"),
|
||
MinChars: 1,
|
||
},
|
||
},
|
||
expected: []rune("abcdefghij"),
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
actual := getChars(test.rules)
|
||
if !reflect.DeepEqual(actual, test.expected) {
|
||
t.Fatalf("Actual: %v\nExpected: %v", actual, test.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDeduplicateRunes(t *testing.T) {
|
||
type testCase struct {
|
||
input []rune
|
||
expected []rune
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"empty string": {
|
||
input: []rune(""),
|
||
expected: []rune(nil),
|
||
},
|
||
"no duplicates": {
|
||
input: []rune("abcde"),
|
||
expected: []rune("abcde"),
|
||
},
|
||
"in order duplicates": {
|
||
input: []rune("aaaabbbbcccccccddddeeeee"),
|
||
expected: []rune("abcde"),
|
||
},
|
||
"out of order duplicates": {
|
||
input: []rune("abcdeabcdeabcdeabcde"),
|
||
expected: []rune("abcde"),
|
||
},
|
||
"unicode no duplicates": {
|
||
input: []rune("日本語"),
|
||
expected: []rune("日本語"),
|
||
},
|
||
"unicode in order duplicates": {
|
||
input: []rune("日日日日本本本語語語語語"),
|
||
expected: []rune("日本語"),
|
||
},
|
||
"unicode out of order duplicates": {
|
||
input: []rune("日本語日本語日本語日本語"),
|
||
expected: []rune("日本語"),
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(name, func(t *testing.T) {
|
||
actual := deduplicateRunes(test.input)
|
||
if !reflect.DeepEqual(actual, test.expected) {
|
||
t.Fatalf("Actual: %#v\nExpected:%#v", actual, test.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRandomRunes_Bias(t *testing.T) {
|
||
type testCase struct {
|
||
charset []rune
|
||
maxStdDev float64
|
||
}
|
||
|
||
tests := map[string]testCase{
|
||
"small charset": {
|
||
charset: []rune("abcde"),
|
||
maxStdDev: 2700,
|
||
},
|
||
"lowercase characters": {
|
||
charset: LowercaseRuneset,
|
||
maxStdDev: 1000,
|
||
},
|
||
"alphabetical characters": {
|
||
charset: AlphabeticRuneset,
|
||
maxStdDev: 800,
|
||
},
|
||
"alphanumeric": {
|
||
charset: AlphaNumericRuneset,
|
||
maxStdDev: 800,
|
||
},
|
||
"alphanumeric with symbol": {
|
||
charset: AlphaNumericShortSymbolRuneset,
|
||
maxStdDev: 800,
|
||
},
|
||
"charset evenly divisible into 256": {
|
||
charset: append(AlphaNumericRuneset, '!', '@'),
|
||
maxStdDev: 800,
|
||
},
|
||
"large charset": {
|
||
charset: FullSymbolRuneset,
|
||
maxStdDev: 800,
|
||
},
|
||
"just under half size charset": {
|
||
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğ"),
|
||
maxStdDev: 800,
|
||
},
|
||
"half size charset": {
|
||
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
|
||
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ"),
|
||
maxStdDev: 800,
|
||
},
|
||
}
|
||
|
||
for name, test := range tests {
|
||
t.Run(fmt.Sprintf("%s (%d chars)", name, len(test.charset)), func(t *testing.T) {
|
||
runeCounts := map[rune]int{}
|
||
|
||
generations := 50000
|
||
length := 100
|
||
for i := 0; i < generations; i++ {
|
||
str, err := randomRunes(nil, test.charset, length)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, r := range str {
|
||
runeCounts[r]++
|
||
}
|
||
}
|
||
|
||
chars := charCounts{}
|
||
|
||
var sum float64
|
||
for r, count := range runeCounts {
|
||
chars = append(chars, charCount{r, count})
|
||
sum += float64(count)
|
||
}
|
||
|
||
mean := sum / float64(len(runeCounts))
|
||
var stdDev float64
|
||
for _, count := range runeCounts {
|
||
stdDev += math.Pow(float64(count)-mean, 2)
|
||
}
|
||
|
||
stdDev = math.Sqrt(stdDev / float64(len(runeCounts)))
|
||
t.Logf("Mean : %10.4f", mean)
|
||
|
||
if stdDev > test.maxStdDev {
|
||
t.Fatalf("Standard deviation is too large: %.2f > %.2f", stdDev, test.maxStdDev)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
type charCount struct {
|
||
r rune
|
||
count int
|
||
}
|
||
|
||
type charCounts []charCount
|
||
|
||
func (s charCounts) Len() int { return len(s) }
|
||
func (s charCounts) Less(i, j int) bool { return s[i].r < s[j].r }
|
||
func (s charCounts) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|