Files
sm-user-plugin/classes/device_auth_test.go
Jakub Zych 1f3266a417 test: device authorization flow and its migration
- TestDeviceCodes (classes, short): tokens, short-code alphabet and
  distribution, normalization
- TestDeviceAuthURL, TestDeviceDisplayName (short): auth_url template, TTL,
  PHP's user-agent order
- TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize: PHP bodies for
  every state and refusal, bounded cleanup, short-code collision retry,
  concurrent authorizes binding one user
- TestDeviceHandOverConcurrent, TestDeviceListAndRevoke,
  TestDeviceRevokeAfterRefresh, TestDeviceTokensAtRest
- TestDeviceAuthSessionsMigration (updates): columns, constraints, indexes,
  cascade, rollback and re-up
2026-10-26 14:15:54 +01:00

130 lines
4.0 KiB
Go

package classes
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
"testing"
)
// TestDeviceCodes pins the device token and short code (D-11, D-06)
// without a database: 10,000 tokens are 64 base64url characters, all
// distinct, and their stored form is the SHA-256 hex; 10,000 short codes
// are XXXX-XXXX over DeviceShortCodeAlphabet with every character of the
// alphabet in every position and no character far from its expected
// share; NormalizeDeviceShortCode accepts case, spaces and a missing
// hyphen and refuses lengths other than eight and the excluded letters.
func TestDeviceCodes(t *testing.T) {
const draws = 10000
t.Run("tokens", func(t *testing.T) {
seen := make(map[string]struct{}, draws)
for range draws {
tok, err := NewDeviceToken()
if err != nil {
t.Fatal(err)
}
if len(tok) != 64 {
t.Fatalf("token %q has %d characters", tok, len(tok))
}
if strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
t.Fatalf("token %q is not base64url", tok)
}
if _, dup := seen[tok]; dup {
t.Fatalf("token %q drawn twice", tok)
}
seen[tok] = struct{}{}
}
tok, _ := NewDeviceToken()
sum := sha256.Sum256([]byte(tok))
if got := HashAPIToken(tok); got != hex.EncodeToString(sum[:]) || len(got) != 64 || got == tok {
t.Fatalf("stored form %q is not the SHA-256 hex of the token", got)
}
})
t.Run("short_codes", func(t *testing.T) {
alphabet := DeviceShortCodeAlphabet
if alphabet != "23456789ABCDEFGHJKMNPQRSTUVWXYZ" || len(alphabet) != 31 {
t.Fatalf("alphabet = %q", alphabet)
}
format := regexp.MustCompile(`^[` + alphabet + `]{4}-[` + alphabet + `]{4}$`)
perPosition := make([]map[byte]int, 8)
for i := range perPosition {
perPosition[i] = map[byte]int{}
}
total := map[byte]int{}
seen := map[string]struct{}{}
for range draws {
code, err := NewDeviceShortCode()
if err != nil {
t.Fatal(err)
}
if !format.MatchString(code) {
t.Fatalf("short code %q", code)
}
seen[code] = struct{}{}
raw := code[:4] + code[5:]
for i := 0; i < 8; i++ {
perPosition[i][raw[i]]++
total[raw[i]]++
}
}
// 31^8 codes: 10,000 draws repeating more than a handful would mean
// a broken generator.
if len(seen) < draws-5 {
t.Fatalf("%d distinct codes in %d draws", len(seen), draws)
}
for i, counts := range perPosition {
for j := 0; j < len(alphabet); j++ {
if counts[alphabet[j]] == 0 {
t.Fatalf("character %q never drawn at position %d", alphabet[j], i)
}
}
}
// Each character is expected 80000/31 ≈ 2581 times; a modulo bias
// of the byte draw would push the first 8 characters ~3% up. Allow
// 10% either way, far outside the noise of a fair draw.
expected := float64(8*draws) / float64(len(alphabet))
for j := 0; j < len(alphabet); j++ {
got := float64(total[alphabet[j]])
if got < expected*0.9 || got > expected*1.1 {
t.Fatalf("character %q drawn %v times, expected about %.0f", alphabet[j], got, expected)
}
}
for _, excluded := range "01ILO" {
if total[byte(excluded)] != 0 {
t.Fatalf("excluded character %q drawn", excluded)
}
}
})
t.Run("normalization", func(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"ABCD-EFGH", "ABCD-EFGH"},
{"abcd-efgh", "ABCD-EFGH"},
{"AbCd-eFgH", "ABCD-EFGH"},
{"ABCDEFGH", "ABCD-EFGH"},
{"abcdefgh", "ABCD-EFGH"},
{" ab cd ef gh ", "ABCD-EFGH"},
{"ABCD - EFGH", "ABCD-EFGH"},
{"A-B-C-D-E-F-G-H", "ABCD-EFGH"},
{"2345-6789", "2345-6789"},
{"zzzz zzzz", "ZZZZ-ZZZZ"},
} {
if got, ok := NormalizeDeviceShortCode(c.in); !ok || got != c.want {
t.Errorf("normalize %q = %q, %v; want %q", c.in, got, ok, c.want)
}
}
for _, in := range []string{
"", "-", "ABCDEFG", "ABCD-EFG", "ABCDEFGHJ", "ABCD-EFGHJ",
"ABCDEFG0", "ABCDEFG1", "ABCDEFGI", "ABCDEFGL", "ABCDEFGO", "abcdefgo", "abcdefgl", "abcdefgi",
"ABCD_EFGH", "ABCD.EFGH", "ABCDÉFGH", "ABCD\tEFGH",
} {
if got, ok := NormalizeDeviceShortCode(in); ok {
t.Errorf("normalize %q accepted as %q", in, got)
}
}
})
}