package auth import ( "strings" "testing" "time" ) // defaultTestTTL is long enough that nothing expires mid-test. const defaultTestTTL = 5 * time.Minute func TestPasswordHashingRoundTrip(t *testing.T) { const password = "correct horse battery staple" hash, err := HashPassword(password) if err != nil { t.Fatalf("hash: %v", err) } if strings.Contains(hash, password) { t.Fatal("the hash contains the plaintext password") } if !strings.HasPrefix(hash, "$argon2id$") { t.Errorf("hash = %q, want the Argon2id PHC format", hash) } ok, err := VerifyPassword(hash, password) if err != nil { t.Fatalf("verify: %v", err) } if !ok { t.Error("the correct password did not verify") } ok, err = VerifyPassword(hash, "wrong password entirely") if err != nil { t.Fatalf("verify wrong: %v", err) } if ok { t.Error("an incorrect password verified") } } func TestHashesAreSalted(t *testing.T) { a, err := HashPassword("same password") if err != nil { t.Fatal(err) } b, err := HashPassword("same password") if err != nil { t.Fatal(err) } if a == b { t.Error("two hashes of the same password are identical; the salt is not random") } } func TestVerifyRejectsMalformedHashes(t *testing.T) { for _, bad := range []string{ "", "not-a-hash", "$argon2id$", "$argon2id$v=19$m=1$x$y", "$bcrypt$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA", } { if _, err := VerifyPassword(bad, "password"); err == nil { t.Errorf("VerifyPassword(%q) returned no error for a malformed hash", bad) } } } func TestValidatePassword(t *testing.T) { tests := []struct { name string in string wantErr bool }{ {"long enough", "a-perfectly-fine-phrase", false}, {"exactly the minimum", strings.Repeat("x", 12), true}, // repeated character {"mixed at the minimum", "aB3$xY9!zQ2w", false}, {"too short", "short", true}, {"empty", "", true}, {"contains password", "mypassword123456", true}, {"contains the product name", "vibedns-is-great-here", true}, {"single repeated character", strings.Repeat("a", 20), true}, {"control character", "abcdefghijkl\x00mnop", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := ValidatePassword(tc.in) if tc.wantErr && err == nil { t.Error("expected an error, got none") } if !tc.wantErr && err != nil { t.Errorf("unexpected error: %v", err) } }) } } func TestGeneratePassword(t *testing.T) { seen := map[string]bool{} for i := 0; i < 50; i++ { p, err := GeneratePassword(20) if err != nil { t.Fatalf("generate: %v", err) } if len(p) != 20 { t.Fatalf("length = %d, want 20", len(p)) } if seen[p] { t.Fatal("generated the same password twice") } seen[p] = true if err := ValidatePassword(p); err != nil { t.Errorf("a generated password failed the policy: %v", err) } } // Short requests are raised to a safe floor rather than honoured. p, _ := GeneratePassword(4) if len(p) < 12 { t.Errorf("short request produced %d characters, want at least 12", len(p)) } } func TestTokenGeneration(t *testing.T) { tok, err := GenerateToken() if err != nil { t.Fatalf("generate: %v", err) } if !strings.HasPrefix(tok.Secret, "vibedns_") { t.Errorf("secret = %q, want the vibedns_ prefix for secret scanners", tok.Secret) } if len(tok.Prefix) != TokenPrefixLen { t.Errorf("prefix length = %d, want %d", len(tok.Prefix), TokenPrefixLen) } if strings.Contains(tok.Hash, tok.Secret) { t.Error("the stored hash contains the secret") } if !VerifyToken(tok.Hash, tok.Secret) { t.Error("the generated token did not verify against its own hash") } if VerifyToken(tok.Hash, "vibedns_someothervalue") { t.Error("a different token verified against the hash") } got, err := TokenPrefix(tok.Secret) if err != nil { t.Fatalf("prefix: %v", err) } if got != tok.Prefix { t.Errorf("extracted prefix = %q, want %q", got, tok.Prefix) } } func TestTokensAreUnique(t *testing.T) { seen := map[string]bool{} for i := 0; i < 100; i++ { tok, err := GenerateToken() if err != nil { t.Fatal(err) } if seen[tok.Secret] { t.Fatal("generated the same token twice") } seen[tok.Secret] = true } } func TestTokenPrefixRejectsShortInput(t *testing.T) { if _, err := TokenPrefix("vibedns_ab"); err == nil { t.Error("expected an error for a truncated token") } } func TestCSRFTokenLifecycle(t *testing.T) { a := New(nil, nil, []byte("a-test-signing-key-of-sufficient-length")) token := a.IssueCSRFToken("admin") if token == "" { t.Fatal("no token issued") } if !a.ValidateCSRFToken(token, "admin") { t.Error("a freshly issued token did not validate") } // A token is bound to the account it was issued for. if a.ValidateCSRFToken(token, "someone-else") { t.Error("a token validated for a different account") } if a.ValidateCSRFToken("garbage", "admin") { t.Error("a garbage token validated") } if a.ValidateCSRFToken("", "admin") { t.Error("an empty token validated") } // A token signed with a different key must not validate. other := New(nil, nil, []byte("a-completely-different-signing-key-xx")) if other.ValidateCSRFToken(token, "admin") { t.Error("a token validated under a different signing key") } } func TestNeedsRehash(t *testing.T) { current, err := HashPassword("some password here") if err != nil { t.Fatal(err) } if NeedsRehash(current) { t.Error("a hash produced with the current parameters should not need rehashing") } // A hash with weaker parameters should be upgraded on next sign-in. weak := "$argon2id$v=19$m=1024,t=1,p=1$c2FsdHNhbHQ$aGFzaGhhc2hoYXNoaGFzaA" if !NeedsRehash(weak) { t.Error("a weak hash should be flagged for rehashing") } if !NeedsRehash("not-a-hash") { t.Error("an unparseable hash should be flagged for rehashing") } } func TestCredentialCache(t *testing.T) { c := newCredentialCache(defaultTestTTL) const user, pass, hash = "admin", "the password", "stored-hash-value" if c.valid(user, pass, hash) { t.Error("an empty cache reported a valid credential") } c.store(user, pass, hash) if !c.valid(user, pass, hash) { t.Error("a stored credential did not validate") } if c.valid(user, "wrong password", hash) { t.Error("a wrong password validated against the cache") } // A changed stored hash means the password was rotated: the cached entry // must stop being authoritative immediately. if c.valid(user, pass, "a-different-stored-hash") { t.Error("the cache validated against a stale password hash") } c.reset() if c.valid(user, pass, hash) { t.Error("the cache still validated after being reset") } } func TestAttemptLimiter(t *testing.T) { l := newAttemptLimiter(3, defaultTestTTL) const key = "192.0.2.1" if !l.allow(key) { t.Fatal("a fresh address was blocked") } for i := 0; i < 3; i++ { l.fail(key) } if l.allow(key) { t.Error("the address should be locked out after reaching the failure limit") } // A different address is unaffected. if !l.allow("192.0.2.2") { t.Error("an unrelated address was locked out") } // A success clears the record. l2 := newAttemptLimiter(3, defaultTestTTL) l2.fail(key) l2.fail(key) l2.succeed(key) l2.fail(key) if !l2.allow(key) { t.Error("a successful sign-in should reset the failure count") } }