137 lines
4.7 KiB
Go
137 lines
4.7 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func productionConfigForTest() Config {
|
|
return Config{
|
|
Port: 8888,
|
|
DSN: "xingyu:password@tcp(mysql:3306)/im",
|
|
JWTSecret: "0123456789abcdef0123456789abcdef",
|
|
ConfigEncryptionKey: "abcdef0123456789abcdef0123456789",
|
|
Environment: "production",
|
|
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
|
|
}
|
|
}
|
|
|
|
func TestValidateProductionConfig(t *testing.T) {
|
|
config := productionConfigForTest()
|
|
if err := validateConfig(config); err != nil {
|
|
t.Fatalf("expected a valid production config: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Config)
|
|
}{
|
|
{"root database account", func(config *Config) { config.DSN = "root:root@tcp(mysql:3306)/im" }},
|
|
{"weak jwt", func(config *Config) { config.JWTSecret = "short" }},
|
|
{"missing encryption key", func(config *Config) { config.ConfigEncryptionKey = "" }},
|
|
{"wildcard cors", func(config *Config) { config.AllowedOrigins = []string{"*"} }},
|
|
{"insecure origin", func(config *Config) { config.AllowedOrigins = []string{"http://app.example.com"} }},
|
|
{"demo seed", func(config *Config) { config.SeedDemo = true }},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
invalid := config
|
|
test.mutate(&invalid)
|
|
if err := validateConfig(invalid); err == nil {
|
|
t.Fatal("expected production validation to reject config")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPhoneEncryptionRoundTrip(t *testing.T) {
|
|
application := &App{config: productionConfigForTest()}
|
|
phone := "13800138000"
|
|
first, err := application.encryptPhone(phone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := application.encryptPhone(phone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Equal(first, second) {
|
|
t.Fatal("phone encryption must use a unique random nonce")
|
|
}
|
|
decrypted, err := application.decryptPhone(first)
|
|
if err != nil || decrypted != phone {
|
|
t.Fatalf("unexpected decrypted phone %q: %v", decrypted, err)
|
|
}
|
|
}
|
|
|
|
func TestProductionURLValidation(t *testing.T) {
|
|
if !validHTTPSURL("https://pay.example.com/create") {
|
|
t.Fatal("expected HTTPS URL to be accepted")
|
|
}
|
|
for _, value := range []string{"http://pay.example.com", "javascript:alert(1)", "https:///missing-host", ""} {
|
|
if validHTTPSURL(value) {
|
|
t.Fatalf("expected URL to be rejected: %s", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPasswordAndPhoneRules(t *testing.T) {
|
|
if validUserPassword("") {
|
|
t.Fatal("an account must still have a password")
|
|
}
|
|
if !validPhone("13800138000") || validPhone("23800138000") || validPhone("1380013800x") {
|
|
t.Fatal("phone policy is not enforced")
|
|
}
|
|
}
|
|
|
|
func TestPasswordsWithoutStrengthRestrictions(t *testing.T) {
|
|
for _, password := range []string{"1", "123456", "a", "password", "!", "中", " ", strings.Repeat("a", 72), strings.Repeat("a", 73), strings.Repeat("密码", 100)} {
|
|
if !validUserPassword(password) {
|
|
t.Fatalf("password with %d bytes was rejected by policy", len(password))
|
|
}
|
|
hash, err := hashPassword(password)
|
|
if err != nil || !checkPassword(hash, password) {
|
|
t.Fatalf("password with %d bytes cannot be stored and verified: %v", len(password), err)
|
|
}
|
|
if checkPassword(hash, password+"x") || checkPassword(hash, "") {
|
|
t.Fatal("password comparison ignored a suffix or accepted an empty password")
|
|
}
|
|
config := productionConfigForTest()
|
|
config.BootstrapAdminPassword = password
|
|
if err := validateConfig(config); err != nil {
|
|
t.Fatal("bootstrap admin still enforces password strength:", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongPasswordHashesAndLegacyCompatibility(t *testing.T) {
|
|
legacyPassword := strings.Repeat("x", 72)
|
|
legacy, err := bcrypt.GenerateFromPassword([]byte(legacyPassword), bcrypt.MinCost)
|
|
if err != nil || !checkPassword(string(legacy), legacyPassword) || checkPassword(string(legacy), legacyPassword+"suffix") {
|
|
t.Fatal("legacy bcrypt credentials must work without accepting truncated input")
|
|
}
|
|
password := legacyPassword + "a"
|
|
first, err := hashPassword(password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := hashPassword(password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first == second || !strings.HasPrefix(first, longPasswordHashPrefix) || len(first) > 255 {
|
|
t.Fatal("long passwords require unique salts and a hash that fits existing storage")
|
|
}
|
|
if !checkPassword(first, password) || checkPassword(first, legacyPassword+"b") || checkPassword(first, legacyPassword) {
|
|
t.Fatal("the entire long password must participate in verification")
|
|
}
|
|
for _, invalid := range []string{"", "plaintext", "$argon2id$", strings.Replace(first, "m=19456", "m=999999999", 1), first[:len(first)-1], longPasswordHashPrefix + strings.Repeat("!", 66)} {
|
|
if checkPassword(invalid, password) {
|
|
t.Fatal("malformed hash was accepted")
|
|
}
|
|
}
|
|
}
|