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
This commit is contained in:
@@ -355,4 +355,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
||||
|
||||
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
|
||||
|
||||
The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a deactivated or banned user's session becomes revoked); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay.
|
||||
|
||||
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
||||
|
||||
129
classes/device_auth_test.go
Normal file
129
classes/device_auth_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
1091
device_auth_test.go
Normal file
1091
device_auth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
192
updates/device_auth_sessions_test.go
Normal file
192
updates/device_auth_sessions_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestDeviceAuthSessionsMigration pins 202610260001_create_device_auth_sessions
|
||||
// (D-16, D-06, D-12b) on a database of its own: the PHP table and column
|
||||
// names plus jwt_jti, the column types and nullability of PHP's v2.1.0 to
|
||||
// v2.7.1 migrations, the unique token and short_code, the CHECK on status,
|
||||
// the indexes, the cascade on user delete, the rollback and a second up.
|
||||
func TestDeviceAuthSessionsMigration(t *testing.T) {
|
||||
sqlDB := dedicatedDB(t, "user_device_auth_sessions")
|
||||
db, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All())
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const table = "golem15_user_device_auth_sessions"
|
||||
if len(deviceAuthSessionMigrations) != 1 || deviceAuthSessionMigrations[0].ID != "202610260001_create_device_auth_sessions" {
|
||||
t.Fatalf("device migrations = %v", deviceAuthSessionMigrations)
|
||||
}
|
||||
|
||||
type column struct {
|
||||
Name string
|
||||
Type string
|
||||
Length *int
|
||||
Nullable string
|
||||
}
|
||||
var columns []column
|
||||
if err := db.Raw(`SELECT column_name AS name, data_type AS type, character_maximum_length AS length, is_nullable AS nullable
|
||||
FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position`, table).Scan(&columns).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]column{}
|
||||
var names []string
|
||||
for _, c := range columns {
|
||||
got[c.Name] = c
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
want := []string{
|
||||
// PHP's columns (v2.1.0, v2.3.1 short_code).
|
||||
"id", "token", "short_code", "user_id", "status", "expires_at", "device_ip", "device_user_agent", "device_name",
|
||||
"auth_ip", "confirmed_at", "used_at", "session_id", "last_activity_at",
|
||||
// D-12b.
|
||||
"jwt_jti",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
if !slices.Equal(names, want) {
|
||||
t.Fatalf("columns = %v, want %v", names, want)
|
||||
}
|
||||
for name, spec := range map[string]struct {
|
||||
typ string
|
||||
length int
|
||||
nullable string
|
||||
}{
|
||||
"token": {"character varying", 64, "NO"},
|
||||
"short_code": {"character varying", 9, "YES"},
|
||||
"user_id": {"integer", 0, "YES"},
|
||||
"status": {"character varying", 16, "NO"},
|
||||
"expires_at": {"timestamp with time zone", 0, "YES"},
|
||||
"device_name": {"character varying", 255, "YES"},
|
||||
"device_user_agent": {"text", 0, "YES"},
|
||||
"jwt_jti": {"character varying", 64, "YES"},
|
||||
"session_id": {"character varying", 255, "YES"},
|
||||
} {
|
||||
c := got[name]
|
||||
length := 0
|
||||
if c.Length != nil {
|
||||
length = *c.Length
|
||||
}
|
||||
if c.Type != spec.typ || length != spec.length || c.Nullable != spec.nullable {
|
||||
t.Fatalf("%s = %s(%d) nullable %s, want %s(%d) nullable %s", name, c.Type, length, c.Nullable, spec.typ, spec.length, spec.nullable)
|
||||
}
|
||||
}
|
||||
|
||||
var indexes []string
|
||||
if err := db.Raw(`SELECT indexdef FROM pg_indexes WHERE tablename = ?`, table).Scan(&indexes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(indexes, "\n")
|
||||
for _, def := range []string{
|
||||
"UNIQUE INDEX " + table + "_token_key ON public." + table + " USING btree (token)",
|
||||
"UNIQUE INDEX " + table + "_short_code_key ON public." + table + " USING btree (short_code)",
|
||||
"INDEX " + table + "_user_id_index ON public." + table + " USING btree (user_id)",
|
||||
"INDEX " + table + "_status_index ON public." + table + " USING btree (status)",
|
||||
"INDEX " + table + "_expires_at_index ON public." + table + " USING btree (expires_at)",
|
||||
"INDEX " + table + "_jwt_jti_index ON public." + table + " USING btree (jwt_jti)",
|
||||
} {
|
||||
if !strings.Contains(joined, def) {
|
||||
t.Fatalf("index %q missing from:\n%s", def, joined)
|
||||
}
|
||||
}
|
||||
|
||||
pgCode := func(err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
insert := func(token string, shortCode any, status string, userID any) error {
|
||||
return db.Exec(`INSERT INTO `+table+` (token, short_code, status, user_id, expires_at) VALUES (?, ?, ?, ?, NOW())`,
|
||||
token, shortCode, status, userID).Error
|
||||
}
|
||||
if err := insert("t1", "AAAA-AAAA", "pending", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := pgCode(insert("t1", "BBBB-BBBB", "pending", nil)); code != "23505" {
|
||||
t.Fatalf("duplicate token: SQLSTATE %q, want 23505", code)
|
||||
}
|
||||
if code := pgCode(insert("t2", "AAAA-AAAA", "pending", nil)); code != "23505" {
|
||||
t.Fatalf("duplicate short_code: SQLSTATE %q, want 23505", code)
|
||||
}
|
||||
if err := insert("t3", nil, "used", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := insert("t4", nil, "revoked", nil); err != nil {
|
||||
t.Fatalf("a second NULL short_code: %v", err)
|
||||
}
|
||||
for _, status := range []string{"confirmed", "expired"} {
|
||||
if err := insert("t-"+status, nil, status, nil); err != nil {
|
||||
t.Fatalf("status %s refused: %v", status, err)
|
||||
}
|
||||
}
|
||||
for _, status := range []string{"unknown", "PENDING", ""} {
|
||||
if code := pgCode(insert("t-bad-"+status, nil, status, nil)); code != "23514" {
|
||||
t.Fatalf("status %q: SQLSTATE %q, want 23514 (check)", status, code)
|
||||
}
|
||||
}
|
||||
var status string
|
||||
if err := db.Raw(`INSERT INTO ` + table + ` (token) VALUES ('t-default') RETURNING status`).Scan(&status).Error; err != nil || status != "pending" {
|
||||
t.Fatalf("default status = %q (%v)", status, err)
|
||||
}
|
||||
|
||||
user := &models.User{Email: "device-migration@example.test", Password: "x"}
|
||||
if err := db.Omit("Groups").Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := pgCode(insert("t-fk", nil, "pending", 987654321)); code != "23503" {
|
||||
t.Fatalf("unknown user_id: SQLSTATE %q, want 23503", code)
|
||||
}
|
||||
if err := insert("t-owned", nil, "used", user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`DELETE FROM users WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var owned int64
|
||||
if err := db.Raw(`SELECT COUNT(*) FROM ` + table + ` WHERE token = 't-owned'`).Scan(&owned).Error; err != nil || owned != 0 {
|
||||
t.Fatalf("rows of a deleted user = %d (%v), want 0 (ON DELETE CASCADE)", owned, err)
|
||||
}
|
||||
|
||||
history := func() int64 {
|
||||
var n int64
|
||||
if err := db.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, deviceAuthSessionMigrations[0].ID).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
if history() != 1 {
|
||||
t.Fatal("the migration is not recorded")
|
||||
}
|
||||
if err := m.RollbackMigration(deviceAuthSessionMigrations[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.Migrator().HasTable(table) || history() != 0 {
|
||||
t.Fatal("rollback left the table or its history row")
|
||||
}
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(table) || history() != 1 {
|
||||
t.Fatal("a second up did not recreate the table")
|
||||
}
|
||||
var rows int64
|
||||
if err := db.Session(&gorm.Session{}).Raw(`SELECT COUNT(*) FROM ` + table).Scan(&rows).Error; err != nil || rows != 0 {
|
||||
t.Fatalf("the recreated table has %d rows (%v)", rows, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user