Files
krow_backend/go-api/internal/auth/token_test.go
2026-08-24 13:06:29 +05:30

159 lines
4.7 KiB
Go

package auth
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
)
// 6. Session tokens carry real entropy from crypto/rand.
//
// Randomness cannot be proved by a test, so this asserts the properties whose
// absence would mean the generator is broken: the full 256 bits are present,
// the output is not a constant, and the bytes are not all the same value —
// which is what a zeroed or unseeded buffer looks like.
func TestGenerateTokenIsCryptographicallyRandom(t *testing.T) {
const runs = 512
seen := make(map[string]struct{}, runs)
bitsSet := make([]int, 8*TokenBytes) // how often each bit position was 1
for i := 0; i < runs; i++ {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
t.Fatalf("token is not base64url: %v", err)
}
if len(raw) != TokenBytes {
t.Fatalf("token decodes to %d bytes, want %d", len(raw), TokenBytes)
}
// A cookie value must survive a round trip untouched: no '=' padding,
// no '+' or '/' to be re-encoded.
if strings.ContainsAny(token, "=+/") {
t.Fatalf("token contains a character that is unsafe in a cookie or URL")
}
if _, dup := seen[token]; dup {
t.Fatalf("GenerateToken returned a duplicate within %d calls", runs)
}
seen[token] = struct{}{}
for bit := 0; bit < 8*TokenBytes; bit++ {
if raw[bit/8]&(1<<(bit%8)) != 0 {
bitsSet[bit]++
}
}
}
// Each bit should be 1 about half the time. A bit that is *always* 0 or
// always 1 across 512 draws has a chance of roughly 2^-511 of being random
// and is far more likely a stuck generator. The bound is deliberately
// loose — this is a smoke test for a broken source, not a statistical
// suite, and it must never flake.
for bit, count := range bitsSet {
if count == 0 || count == runs {
t.Errorf("bit %d was constant across %d tokens; the entropy source is broken", bit, runs)
}
}
// 256 bits is the size that makes guessing a live session hopeless.
if TokenBytes < 32 {
t.Errorf("TokenBytes = %d, want at least 32", TokenBytes)
}
}
// 7. Two generated tokens differ.
func TestGenerateTokenReturnsDistinctValues(t *testing.T) {
a, err := GenerateToken()
if err != nil {
t.Fatalf("first: %v", err)
}
b, err := GenerateToken()
if err != nil {
t.Fatalf("second: %v", err)
}
if a == b {
t.Fatal("two consecutive tokens were identical")
}
if HashToken(a) == HashToken(b) {
t.Fatal("two distinct tokens hashed to the same value")
}
}
// 8. Hashing is deterministic, and is genuinely SHA-256 rather than something
// that merely looks like it.
func TestHashTokenIsDeterministicSHA256(t *testing.T) {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
first, second := HashToken(token), HashToken(token)
if first != second {
t.Fatal("hashing the same token twice produced different values")
}
// Checked against the standard library directly: lookup only works if the
// value stored is exactly this.
want := sha256.Sum256([]byte(token))
if first != hex.EncodeToString(want[:]) {
t.Fatal("HashToken does not agree with crypto/sha256")
}
if len(first) != 64 {
t.Fatalf("hash is %d characters, want 64 hex characters", len(first))
}
if first != strings.ToLower(first) {
t.Error("hash is not lowercase; the database CHECK requires lowercase hex")
}
// The stored value must not be the secret.
if strings.Contains(first, token) || first == token {
t.Error("the hash contains the token")
}
if HashToken(token+"x") == first {
t.Error("a different token hashed to the same value")
}
// The empty string has a hash too — that is a property of SHA-256, not a
// licence to store one. Guarding against an empty token is the Manager's
// job, and TestManagerRejectsEmptyToken covers it.
if HashToken("") == "" {
t.Error("HashToken returned an empty string")
}
}
// IsTokenHash is the guard that stops a raw token being written where a hash
// belongs, so it must reject every raw token and accept every real hash.
func TestIsTokenHash(t *testing.T) {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if !IsTokenHash(HashToken(token)) {
t.Error("a real hash was not recognised as one")
}
if IsTokenHash(token) {
t.Error("a raw token was accepted as a hash; this is the check that prevents storing the secret")
}
for name, s := range map[string]string{
"empty": "",
"too short": strings.Repeat("a", 63),
"too long": strings.Repeat("a", 65),
"uppercase": strings.ToUpper(HashToken(token)),
"non-hex": strings.Repeat("g", 64),
"trailing": HashToken(token) + "\n",
} {
if IsTokenHash(s) {
t.Errorf("%s was accepted as a token hash", name)
}
}
}