package user import ( "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "slices" "strconv" "strings" "sync" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "github.com/golang-jwt/jwt/v5" ) // TestDeviceAuthURL pins the auth_url template and the session TTL (D-11) // without a database. func TestDeviceAuthURL(t *testing.T) { if got := classes.DeviceAuthURL(nil, "tok"); got != "/device/scan/tok" { t.Fatalf("auth_url without an app = %q", got) } if got := classes.DeviceAuthTTL(nil); got != 5*time.Minute { t.Fatalf("ttl without an app = %s", got) } app := backpack.New(sessionConfig(t)) set := func(key string, value any) { t.Helper() if err := app.Config.Set(key, value); err != nil { t.Fatal(err) } } for _, step := range []struct { key string value any want string }{ {"", nil, "/device/scan/tok-1_A"}, {"app.url", "https://api.example.test", "https://api.example.test/device/scan/tok-1_A"}, {"app.url", "https://api.example.test///", "https://api.example.test/device/scan/tok-1_A"}, {"golem15.user.frontend_url", " https://www.example.test/ ", "https://www.example.test/device/scan/tok-1_A"}, {"golem15.user.device_auth.auth_url", " ", "https://www.example.test/device/scan/tok-1_A"}, {"golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}", "https://www.example.test/link?device=tok-1_A"}, {"golem15.user.device_auth.auth_url", "https://tv.example.test/pair/{token}/{token}", "https://tv.example.test/pair/tok-1_A/tok-1_A"}, {"golem15.user.device_auth.auth_url", "{frontend_url}/pair", "https://www.example.test/pair"}, } { if step.key != "" { set(step.key, step.value) } if got := classes.DeviceAuthURL(app, "tok-1_A"); got != step.want { t.Fatalf("after %s=%v: auth_url = %q, want %q", step.key, step.value, got, step.want) } } for _, c := range []struct { minutes any want time.Duration }{ {nil, 5 * time.Minute}, {10, 10 * time.Minute}, {1, time.Minute}, {0, 5 * time.Minute}, {-3, 5 * time.Minute}, } { if c.minutes != nil { set("golem15.user.device_auth.ttl_minutes", c.minutes) } if got := classes.DeviceAuthTTL(app); got != c.want { t.Fatalf("ttl_minutes %v = %s, want %s", c.minutes, got, c.want) } } } // TestDeviceDisplayName pins PHP's device_name accessor order: a stored // name wins; otherwise iPhone, iPad, Android, Windows, Macintosh and Linux // are checked in that order (an iPhone's "like Mac OS X" stays an iPhone, // Android's "Linux" stays Android), then Unknown Device. func TestDeviceDisplayName(t *testing.T) { stored := "Living room TV" empty := "" for _, c := range []struct { name string stored *string ua string want string }{ {"stored_wins", &stored, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", stored}, {"empty_stored_parses", &empty, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Windows PC"}, {"iphone_before_mac", nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", "iPhone"}, {"ipad", nil, "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)", "iPad"}, {"ipad_desktop_mode_is_mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/17.0 Safari", "Mac"}, {"android_before_linux", nil, "Mozilla/5.0 (Linux; Android 14; Pixel 8)", "Android Device"}, {"windows", nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"}, {"mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"}, {"linux", nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"}, {"smart_tv_on_linux", nil, "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)", "Linux PC"}, {"case_sensitive_like_php", nil, "mozilla/5.0 (iphone)", "Unknown Device"}, {"curl", nil, "curl/8.0", "Unknown Device"}, {"no_user_agent", nil, "", "Unknown Device"}, } { t.Run(c.name, func(t *testing.T) { if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want { t.Fatalf("DeviceDisplayName(%q) = %q, want %q", c.ua, got, c.want) } }) } } // --------------------------------------------------------------------------- // Device flow through the assembled routes (Postgres). // envInitiate posts devices/initiate through the router. func envInitiate(t *testing.T, env *adminEnv, body any) (deviceInitiated, *httptest.ResponseRecorder) { t.Helper() rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", body) var out struct { Data deviceInitiated `json:"data"` } if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } } return out.Data, rec } func envNewDevice(t *testing.T, env *adminEnv) deviceInitiated { t.Helper() dev, rec := envInitiate(t, env, map[string]any{}) if rec.Code != http.StatusOK { t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) } return dev } func envPoll(t *testing.T, env *adminEnv, token string) *httptest.ResponseRecorder { t.Helper() return env.call(t, http.MethodGet, "/_user/api/v1/devices/status/"+token, "", nil) } func envAuthorize(t *testing.T, env *adminEnv, session string, body any) *httptest.ResponseRecorder { t.Helper() return env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize", session, body) } func envDeviceRow(t *testing.T, env *adminEnv, token string) models.DeviceAuthSession { t.Helper() var row models.DeviceAuthSession if err := env.gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil { t.Fatal(err) } return row } func envSetDevice(t *testing.T, env *adminEnv, id uint, values map[string]any) { t.Helper() if err := env.gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", id).Updates(values).Error; err != nil { t.Fatal(err) } } // envSession signs a fresh user in and returns it with its session token. func envSession(t *testing.T, env *adminEnv, label string) (*models.User, string) { t.Helper() user := env.seedUser(t, label, nil) status, token := env.userLogin(t, user.Email, "correct-horse") if status != http.StatusOK || token == "" { t.Fatalf("login %s = %d", label, status) } return user, token } func wantBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || rec.Body.String() != body { t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), status, body) } } const ( deviceAuthorizedBody = `{"message":"Device authorized successfully","success":true}` deviceInvalidBody = `{"error":"Invalid or expired code"}` deviceCodeLengthBody = `{"error":"Please enter a valid 8-character code"}` deviceUnauthorized = `{"error":"Unauthorized"}` deviceExpiredBody = `{"message":"Authorization session has expired","status":"expired","success":true}` deviceNotFoundBody = `{"error":"Session not found"}` deviceRevokedOKBody = `{"message":"Device revoked successfully","success":true}` deviceNotFoundRevoke = `{"error":"Device not found"}` deviceNameTooLongBody = `{"error":"The device name may not be greater than 255 characters.","errors":{"device_name":["The device name may not be greater than 255 characters."]}}` ) func deviceStatusBody(status string) string { return `{"data":{"status":"` + status + `"},"success":true}` } // TestDeviceInitiate pins POST devices/initiate: PHP's answer shape, the // stored pending row, the TTL setting, the device_name bound, the bounded // cleanup of stale sessions and the retry of an insert whose short code // collides with a stored one. func TestDeviceInitiate(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) t.Run("answer_and_row", func(t *testing.T) { before := time.Now() rec := env.send(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", []byte(`{"device_name":"Kitchen TV"}`), "application/json", map[string]string{"User-Agent": "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)"}) if rec.Code != http.StatusOK { t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) } var raw map[string]map[string]any var top map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &top); err != nil || top["success"] != true || len(top) != 2 { t.Fatalf("initiate body %s", rec.Body.String()) } _ = json.Unmarshal(rec.Body.Bytes(), &raw) data := raw["data"] keys := make([]string, 0, len(data)) for k := range data { keys = append(keys, k) } slices.Sort(keys) if strings.Join(keys, ",") != "auth_url,expires_at,short_code,token" { t.Fatalf("data keys = %v", keys) } token, _ := data["token"].(string) code, _ := data["short_code"].(string) if len(token) != 64 || !deviceShortCodeRE.MatchString(code) { t.Fatalf("token %q, short code %q", token, code) } if data["auth_url"] != "https://app.example.test/device/scan/"+token { t.Fatalf("auth_url = %v", data["auth_url"]) } expires, err := time.Parse("2006-01-02T15:04:05-07:00", data["expires_at"].(string)) if err != nil || !strings.HasSuffix(data["expires_at"].(string), "+00:00") { t.Fatalf("expires_at %v: %v", data["expires_at"], err) } if d := expires.Sub(before); d < 4*time.Minute+58*time.Second || d > 5*time.Minute+2*time.Second { t.Fatalf("expires_at is %s after the request, want 5m", d) } row := envDeviceRow(t, env, token) if row.Status != models.DeviceStatusPending || row.UserID != nil || row.ShortCode == nil || *row.ShortCode != code || row.DeviceName == nil || *row.DeviceName != "Kitchen TV" || row.DeviceIP == nil || !strings.HasPrefix(*row.DeviceIP, "198.51.100.") || row.DeviceUserAgent == nil || *row.DeviceUserAgent != "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)" || row.ConfirmedAt != nil || row.UsedAt != nil || row.JWTJTI != nil || row.AuthIP != nil || row.SessionID != nil { t.Fatalf("stored row = %+v", row) } }) t.Run("ttl_setting", func(t *testing.T) { if err := env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 10); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 5) }) dev := envNewDevice(t, env) row := envDeviceRow(t, env, dev.Token) if d := time.Until(*row.ExpiresAt); d < 9*time.Minute+50*time.Second || d > 10*time.Minute+time.Second { t.Fatalf("expires in %s, want 10m", d) } }) t.Run("device_name", func(t *testing.T) { var before, after int64 env.gdb.Model(&models.DeviceAuthSession{}).Count(&before) _, rec := envInitiate(t, env, map[string]any{"device_name": strings.Repeat("n", 256)}) wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody) _, rec = envInitiate(t, env, map[string]any{"device_name": strings.Repeat("ł", 256)}) wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody) env.gdb.Model(&models.DeviceAuthSession{}).Count(&after) if after != before { t.Fatalf("a refused initiate stored %d rows", after-before) } long := strings.Repeat("ł", 255) dev, rec := envInitiate(t, env, map[string]any{"device_name": long}) if rec.Code != http.StatusOK { t.Fatalf("255 characters = %d %s", rec.Code, rec.Body.String()) } if row := envDeviceRow(t, env, dev.Token); row.DeviceName == nil || *row.DeviceName != long { t.Fatal("the 255-character name was not stored as sent") } dev = envNewDevice(t, env) if row := envDeviceRow(t, env, dev.Token); row.DeviceName != nil { t.Fatalf("no device_name stored %q", *row.DeviceName) } }) t.Run("cleanup_is_bounded", func(t *testing.T) { stamp := adminStamp() batch := 0 insert := func(n int, status, expires string) { t.Helper() batch++ if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, status, expires_at) SELECT 'cleanup-' || ? || '-' || ? || '-' || g, ?, `+expires+` FROM generate_series(1, ?) g`, stamp, strconv.Itoa(batch), status, n).Error; err != nil { t.Fatal(err) } } insert(503, "pending", "NOW() - INTERVAL '2 hours'") insert(2, "expired", "NOW() - INTERVAL '3 hours'") insert(1, "pending", "NOW() - INTERVAL '30 minutes'") insert(1, "confirmed", "NOW() - INTERVAL '2 hours'") insert(1, "used", "NOW() - INTERVAL '2 hours'") insert(1, "revoked", "NOW() - INTERVAL '2 hours'") insert(1, "pending", "NULL") stale := func() int64 { var n int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`).Scan(&n).Error; err != nil { t.Fatal(err) } return n } mine := func(where string) int64 { var n int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE token LIKE ? AND `+where, "cleanup-"+stamp+"-%").Scan(&n).Error; err != nil { t.Fatal(err) } return n } before := stale() if before < 505 { t.Fatalf("stale rows before = %d", before) } envNewDevice(t, env) if after := stale(); after != before-500 { t.Fatalf("one initiate deleted %d stale rows, want 500", before-after) } for envNewDevice(t, env); stale() > 0; envNewDevice(t, env) { } if n := mine(`status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`); n != 0 { t.Fatalf("%d stale rows survived", n) } if n := mine(`status = 'pending' AND (expires_at IS NULL OR expires_at > NOW() - INTERVAL '1 hour')`); n != 2 { t.Fatalf("recent and undated pending rows left = %d, want 2", n) } if n := mine(`status IN ('confirmed', 'used', 'revoked')`); n != 3 { t.Fatalf("confirmed, used and revoked rows left = %d, want 3", n) } }) t.Run("short_code_collision_retry", func(t *testing.T) { const taken = "QQQQ-QQQQ" if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, short_code, status, expires_at) VALUES (?, ?, 'pending', NOW() + INTERVAL '5 minutes')`, "collision-"+adminStamp(), taken).Error; err != nil { t.Fatal(err) } // A BEFORE INSERT trigger gives the first `collide` inserts the // stored short code, so the unique index refuses them exactly as a // random collision would. collide := func(n int) { t.Helper() for _, stmt := range []string{ `DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`, `DROP SEQUENCE IF EXISTS device_collision_seq`, `CREATE SEQUENCE device_collision_seq`, `CREATE OR REPLACE FUNCTION device_force_collision() RETURNS trigger AS $$ BEGIN IF nextval('device_collision_seq') <= ` + strconv.Itoa(n) + ` THEN NEW.short_code := '` + taken + `'; END IF; RETURN NEW; END $$ LANGUAGE plpgsql`, `CREATE TRIGGER device_force_collision BEFORE INSERT ON golem15_user_device_auth_sessions FOR EACH ROW EXECUTE FUNCTION device_force_collision()`, } { if err := env.gdb.Exec(stmt).Error; err != nil { t.Fatal(err) } } } t.Cleanup(func() { _ = env.gdb.Exec(`DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`).Error _ = env.gdb.Exec(`DROP FUNCTION IF EXISTS device_force_collision()`).Error _ = env.gdb.Exec(`DROP SEQUENCE IF EXISTS device_collision_seq`).Error }) var before, after int64 fresh := func(n *int64) { t.Helper() if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE status = 'pending' AND expires_at > NOW()`).Scan(n).Error; err != nil { t.Fatal(err) } } collide(2) dev := envNewDevice(t, env) if dev.ShortCode == taken || !deviceShortCodeRE.MatchString(dev.ShortCode) { t.Fatalf("retried initiate answered short code %q", dev.ShortCode) } var calls int64 if err := env.gdb.Raw(`SELECT last_value FROM device_collision_seq`).Scan(&calls).Error; err != nil || calls != 3 { t.Fatalf("insert attempts = %d (%v), want 3", calls, err) } if row := envDeviceRow(t, env, dev.Token); row.ShortCode == nil || *row.ShortCode != dev.ShortCode { t.Fatalf("stored short code = %v", row.ShortCode) } fresh(&before) collide(5) _, rec := envInitiate(t, env, map[string]any{}) if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "token") { t.Fatalf("five collisions = %d %s, want the opaque 500", rec.Code, rec.Body.String()) } fresh(&after) if after != before { t.Fatalf("a failed initiate stored %d rows", after-before) } }) } // TestDeviceStatus pins GET devices/status/{token} for every state: an // unknown token, pending, a pending or confirmed session past expires_at // (PHP's flat expired body; the row becomes expired), an expired row, and // used and revoked sessions, which never become expired (Pitfall 13). func TestDeviceStatus(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) _, session := envSession(t, env, "dst-owner") past := time.Now().Add(-time.Minute) t.Run("unknown", func(t *testing.T) { wantBody(t, envPoll(t, env, strings.Repeat("z", 64)), http.StatusNotFound, deviceNotFoundBody) wantBody(t, envPoll(t, env, "short"), http.StatusNotFound, deviceNotFoundBody) dev := envNewDevice(t, env) // The stored hash is not a token: polling with it finds nothing. wantBody(t, envPoll(t, env, classes.HashAPIToken(dev.Token)), http.StatusNotFound, deviceNotFoundBody) }) t.Run("pending", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending")) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending")) }) t.Run("expired_pending", func(t *testing.T) { dev := envNewDevice(t, env) row := envDeviceRow(t, env, dev.Token) envSetDevice(t, env, row.ID, map[string]any{"expires_at": past}) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) if got := envDeviceRow(t, env, dev.Token).Status; got != models.DeviceStatusExpired { t.Fatalf("row status = %s", got) } wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) }) t.Run("expired_confirmed_hands_nothing_over", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) row := envDeviceRow(t, env, dev.Token) envSetDevice(t, env, row.ID, map[string]any{"expires_at": past}) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) row = envDeviceRow(t, env, dev.Token) if row.Status != models.DeviceStatusExpired || row.UsedAt != nil || row.JWTJTI != nil { t.Fatalf("row = %+v", row) } }) t.Run("expired_row_without_expires_at_passing", func(t *testing.T) { dev := envNewDevice(t, env) row := envDeviceRow(t, env, dev.Token) envSetDevice(t, env, row.ID, map[string]any{"status": models.DeviceStatusExpired}) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) }) t.Run("used_and_revoked_never_expire", func(t *testing.T) { for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} { dev := envNewDevice(t, env) row := envDeviceRow(t, env, dev.Token) envSetDevice(t, env, row.ID, map[string]any{"status": status, "expires_at": time.Now().Add(-24 * time.Hour)}) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody(status)) if got := envDeviceRow(t, env, dev.Token).Status; got != status { t.Fatalf("%s row became %s", status, got) } } }) } // TestDeviceAuthorize pins POST devices/authorize: the short-code forms // and refusals with PHP's 422 and 400 bodies, the token path, sessions // bound to another user, a repeated authorize by the same user, the body- // only fields, expired, used and revoked sessions, concurrent authorizes by // two users binding exactly one, and PHP's 401 for a missing, invalid, // foreign-signed or ended session token. func TestDeviceAuthorize(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) owner, session := envSession(t, env, "dau-owner") other, otherSession := envSession(t, env, "dau-other") t.Run("short_code_forms", func(t *testing.T) { for _, form := range []func(string) string{ func(c string) string { return c }, strings.ToLower, func(c string) string { return strings.ReplaceAll(c, "-", "") }, func(c string) string { return strings.ToLower(strings.ReplaceAll(c, "-", "")) }, func(c string) string { return c[:2] + "-" + c[2:4] + c[5:] }, func(c string) string { return strings.ToLower(c[:4]) + c[4:] }, // Surrounding whitespace is trimmed from the field. func(c string) string { return " " + c + " " }, } { dev := envNewDevice(t, env) code := form(dev.ShortCode) wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": code}), http.StatusOK, deviceAuthorizedBody) row := envDeviceRow(t, env, dev.Token) if row.Status != models.DeviceStatusConfirmed || row.UserID == nil || *row.UserID != owner.ID || row.ConfirmedAt == nil || row.AuthIP == nil || !strings.HasPrefix(*row.AuthIP, "198.51.100.") { t.Fatalf("short code %q: row = %+v", code, row) } } }) t.Run("short_code_refusals", func(t *testing.T) { dev := envNewDevice(t, env) for _, c := range []struct { code string status int body string }{ {"", http.StatusUnprocessableEntity, deviceCodeLengthBody}, {"ABCDEFG", http.StatusUnprocessableEntity, deviceCodeLengthBody}, {"ABCD-EFG", http.StatusUnprocessableEntity, deviceCodeLengthBody}, {"ABCDEFGHJ", http.StatusUnprocessableEntity, deviceCodeLengthBody}, {"ABCD-EFGH-J", http.StatusUnprocessableEntity, deviceCodeLengthBody}, // Inner spaces count toward the length; only hyphens are // removed before the length check, as in PHP. {strings.ReplaceAll(dev.ShortCode, "-", " "), http.StatusUnprocessableEntity, deviceCodeLengthBody}, {"ABCD-EFGO", http.StatusBadRequest, deviceInvalidBody}, {"ABCD-EFG0", http.StatusBadRequest, deviceInvalidBody}, {"ABCD-EFG1", http.StatusBadRequest, deviceInvalidBody}, {"ABCD-EFGI", http.StatusBadRequest, deviceInvalidBody}, {"ABCD-EFGL", http.StatusBadRequest, deviceInvalidBody}, {"ABC EFGH", http.StatusBadRequest, deviceInvalidBody}, } { wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": c.code}), c.status, c.body) } // A well-formed code nobody holds. unknown := "2222-2222" if dev.ShortCode == unknown { unknown = "3333-3333" } wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": unknown}), http.StatusBadRequest, deviceInvalidBody) if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil { t.Fatalf("refusals touched the session: %+v", row) } }) t.Run("token_path_and_missing_fields", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": strings.Repeat("y", 64)}), http.StatusBadRequest, deviceInvalidBody) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": classes.HashAPIToken(dev.Token)}), http.StatusBadRequest, deviceInvalidBody) wantBody(t, envAuthorize(t, env, session, map[string]string{}), http.StatusBadRequest, deviceInvalidBody) // The fields come from the body only, never the query string. wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?token="+dev.Token, session, map[string]string{}), http.StatusBadRequest, deviceInvalidBody) wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?short_code="+dev.ShortCode, session, map[string]string{}), http.StatusBadRequest, deviceInvalidBody) if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending { t.Fatalf("status = %s", row.Status) } wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) // short_code wins when both are sent, as in PHP. dev2 := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": "ABC", "token": dev2.Token}), http.StatusUnprocessableEntity, deviceCodeLengthBody) }) t.Run("bound_to_another_user", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody) wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"short_code": dev.ShortCode}), http.StatusBadRequest, deviceInvalidBody) if row := envDeviceRow(t, env, dev.Token); row.UserID == nil || *row.UserID != owner.ID { t.Fatalf("the session moved to %v", row.UserID) } // A pending session already carrying a user_id binds only to it. pre := envNewDevice(t, env) row := envDeviceRow(t, env, pre.Token) envSetDevice(t, env, row.ID, map[string]any{"user_id": owner.ID}) wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": pre.Token}), http.StatusBadRequest, deviceInvalidBody) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": pre.Token}), http.StatusOK, deviceAuthorizedBody) }) t.Run("same_user_again", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody) first := envDeviceRow(t, env, dev.Token) time.Sleep(10 * time.Millisecond) wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) again := envDeviceRow(t, env, dev.Token) if !again.ConfirmedAt.Equal(*first.ConfirmedAt) || *again.AuthIP != *first.AuthIP { t.Fatal("repeating the authorize rewrote the confirmation") } }) t.Run("expired_used_revoked", func(t *testing.T) { expired := envNewDevice(t, env) row := envDeviceRow(t, env, expired.Token) envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)}) wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": expired.ShortCode}), http.StatusBadRequest, deviceInvalidBody) if got := envDeviceRow(t, env, expired.Token).Status; got != models.DeviceStatusExpired { t.Fatalf("expired session status = %s", got) } // A confirmed session past expires_at cannot be authorized again // either, not even by its own user. late := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusOK, deviceAuthorizedBody) row = envDeviceRow(t, env, late.Token) envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)}) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusBadRequest, deviceInvalidBody) for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} { dev := envNewDevice(t, env) row := envDeviceRow(t, env, dev.Token) envSetDevice(t, env, row.ID, map[string]any{"status": status, "user_id": owner.ID}) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody) if got := envDeviceRow(t, env, dev.Token).Status; got != status { t.Fatalf("%s session became %s", status, got) } } }) t.Run("concurrent_two_users_bind_one", func(t *testing.T) { for round := range 3 { dev := envNewDevice(t, env) var ( wg sync.WaitGroup mu sync.Mutex ok = map[uint]int{} no = map[uint]int{} ) for i := range 10 { who, tok := owner.ID, session if i%2 == 1 { who, tok = other.ID, otherSession } wg.Add(1) go func() { defer wg.Done() rec := envAuthorize(t, env, tok, map[string]string{"short_code": dev.ShortCode}) mu.Lock() defer mu.Unlock() switch { case rec.Code == http.StatusOK && rec.Body.String() == deviceAuthorizedBody: ok[who]++ case rec.Code == http.StatusBadRequest && rec.Body.String() == deviceInvalidBody: no[who]++ default: t.Errorf("round %d: unexpected answer %d %s", round, rec.Code, rec.Body.String()) } }() } wg.Wait() row := envDeviceRow(t, env, dev.Token) if row.UserID == nil { t.Fatalf("round %d: no user bound", round) } winner := *row.UserID loser := owner.ID if winner == owner.ID { loser = other.ID } if ok[winner] != 5 || no[winner] != 0 || ok[loser] != 0 || no[loser] != 5 { t.Fatalf("round %d: winner %d ok=%d no=%d, loser ok=%d no=%d", round, winner, ok[winner], no[winner], ok[loser], no[loser]) } } }) t.Run("unauthorized", func(t *testing.T) { dev := envNewDevice(t, env) body := map[string]string{"short_code": dev.ShortCode} wantBody(t, envAuthorize(t, env, "", body), http.StatusUnauthorized, deviceUnauthorized) wantBody(t, envAuthorize(t, env, "not-a-jwt", body), http.StatusUnauthorized, deviceUnauthorized) foreign := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": "dau-foreign", "prv": prvHash, "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), }) signed, err := foreign.SignedString([]byte("another-secret-entirely-another-secret")) if err != nil { t.Fatal(err) } wantBody(t, envAuthorize(t, env, signed, body), http.StatusUnauthorized, deviceUnauthorized) _, ended := envSession(t, env, "dau-ended") env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/logout", ended, nil) wantBody(t, envAuthorize(t, env, ended, body), http.StatusUnauthorized, deviceUnauthorized) if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil { t.Fatalf("an unauthorized call touched the session: %+v", row) } }) } // TestDeviceHandOverConcurrent pins the one-time hand-over (D-12a): of 50 // concurrent polls of a confirmed session exactly one receives a token, the // others answer used, and one login event with source device fires; a // session whose user was deactivated or banned after the authorize hands // nothing over and becomes revoked. func TestDeviceHandOverConcurrent(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) events := listenLoginEvents(env.app, "test.device-hand-over") owner, session := envSession(t, env, "dho-owner") t.Run("fifty_polls_one_token", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) before := events.count() var ( wg sync.WaitGroup mu sync.Mutex tokens []string used int ) for range 50 { wg.Add(1) go func() { defer wg.Done() rec := envPoll(t, env, dev.Token) var body struct { Success bool `json:"success"` Data map[string]any `json:"data"` } _ = json.Unmarshal(rec.Body.Bytes(), &body) mu.Lock() defer mu.Unlock() switch { case rec.Code == http.StatusOK && body.Data["token"] != nil: tokens = append(tokens, body.Data["token"].(string)) case rec.Code == http.StatusOK && rec.Body.String() == deviceStatusBody("used"): used++ default: t.Errorf("unexpected poll answer %d %s", rec.Code, rec.Body.String()) } }() } wg.Wait() if len(tokens) != 1 || used != 49 { t.Fatalf("50 polls: %d tokens, %d used", len(tokens), used) } sub, _, _, jti, err := bouncer.VerifyClaims(tokens[0], sessionSecret) if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) { t.Fatalf("device token sub %q: %v", sub, err) } row := envDeviceRow(t, env, dev.Token) if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil || row.LastActivityAt == nil { t.Fatalf("row after the hand-over = %+v", row) } env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokens[0], nil) events.mu.Lock() fired := events.got[before:] events.mu.Unlock() if len(fired) != 1 || fired[0].Source != classes.LoginSourceDevice || fired[0].User.ID != owner.ID { t.Fatalf("login events = %+v", fired) } }) for _, c := range []struct { name string after func(*testing.T, *models.User) }{ {"deactivated_user", func(t *testing.T, u *models.User) { if err := env.gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } }}, {"banned_user", func(t *testing.T, u *models.User) { env.seedThrottle(t, u, "", true, nil) }}, } { t.Run(c.name+"_hands_nothing_over", func(t *testing.T) { user, userSession := envSession(t, env, "dho-"+c.name) dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) c.after(t, user) before := events.count() wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) row := envDeviceRow(t, env, dev.Token) if row.Status != models.DeviceStatusRevoked || row.JWTJTI != nil || row.UsedAt != nil { t.Fatalf("row = %+v", row) } if events.count() != before { t.Fatal("a refused hand-over fired a login event") } }) } t.Run("deleted_user_cascades", func(t *testing.T) { user, userSession := envSession(t, env, "dho-gone") dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) if err := env.gdb.Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil { t.Fatal(err) } if err := env.gdb.Unscoped().Delete(&models.User{}, user.ID).Error; err != nil { t.Fatal(err) } wantBody(t, envPoll(t, env, dev.Token), http.StatusNotFound, deviceNotFoundBody) }) } // TestDeviceListAndRevoke pins GET devices and DELETE devices/{id}: only // the caller's confirmed and used sessions, most recent activity first // (nulls last, then newest id), is_current for the requesting token, the // display name, PHP's item keys and an empty list as []; a revoke of a // foreign or unknown id is 404, a non-numeric id never reaches the // handler, a repeated revoke answers success, and a revoked device is // signed out and no longer listed. func TestDeviceListAndRevoke(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) owner, session := envSession(t, env, "dlr-owner") _, otherSession := envSession(t, env, "dlr-other") t.Run("empty", func(t *testing.T) { _, fresh := envSession(t, env, "dlr-empty") wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", fresh, nil), http.StatusOK, `{"devices":[],"success":true}`) wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil), http.StatusUnauthorized, deviceUnauthorized) }) tokA, _, idA := envDevice(t, env, session, "A") _, _, idB := envDevice(t, env, session, "B") _, _, idC := envDevice(t, env, session, "") _, _, idD := envDevice(t, env, session, "D") _, _, foreign := envDevice(t, env, otherSession, "Foreign") hour := time.Now().Add(-time.Hour).Truncate(time.Second) envSetDevice(t, env, idA, map[string]any{"last_activity_at": time.Now().Add(-3 * time.Hour)}) envSetDevice(t, env, idB, map[string]any{"last_activity_at": hour}) envSetDevice(t, env, idC, map[string]any{"last_activity_at": nil, "device_user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"}) envSetDevice(t, env, idD, map[string]any{"last_activity_at": hour}) // Sessions of the owner that are not listed. for _, status := range []string{models.DeviceStatusPending, models.DeviceStatusExpired, models.DeviceStatusRevoked} { row := models.DeviceAuthSession{TokenHash: "dlr-" + status + "-" + adminStamp(), UserID: &owner.ID, Status: status} if err := env.gdb.Create(&row).Error; err != nil { t.Fatal(err) } } t.Run("order_names_and_current", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", tokA, nil) var raw struct { Success bool `json:"success"` Devices []map[string]any `json:"devices"` } if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil || !raw.Success { t.Fatalf("list %s: %v", rec.Body.String(), err) } var ids []uint for _, d := range raw.Devices { keys := make([]string, 0, len(d)) for k := range d { keys = append(keys, k) } slices.Sort(keys) if strings.Join(keys, ",") != "authorized_at,device_ip,device_name,id,is_current,last_activity" { t.Fatalf("item keys = %v", keys) } ids = append(ids, uint(d["id"].(float64))) } want := []uint{idD, idB, idA, idC} if !slices.Equal(ids, want) { t.Fatalf("order = %v, want %v (D and B tie on activity, newest id first; C has none)", ids, want) } names := map[uint]string{} current := map[uint]bool{} for _, d := range raw.Devices { names[uint(d["id"].(float64))] = d["device_name"].(string) current[uint(d["id"].(float64))] = d["is_current"].(bool) if at, _ := d["authorized_at"].(string); !strings.HasSuffix(at, "+00:00") { t.Fatalf("authorized_at = %v", d["authorized_at"]) } } if names[idA] != "A" || names[idC] != "iPhone" || names[idD] != "D" { t.Fatalf("names = %v", names) } if !current[idA] || current[idB] || current[idC] || current[idD] { t.Fatalf("is_current = %v, want only A", current) } if last := raw.Devices[3]["last_activity"]; last != nil { t.Fatalf("last_activity of C = %v, want null", last) } for _, d := range envDevices(t, env, session).Devices { if d.IsCurrent { t.Fatal("the owner's own login session is not a device") } } }) t.Run("revoke", func(t *testing.T) { path := func(id uint) string { return "/_user/api/v1/devices/" + strconv.FormatUint(uint64(id), 10) } wantBody(t, env.call(t, http.MethodDelete, path(idB), "", nil), http.StatusUnauthorized, deviceUnauthorized) wantBody(t, env.call(t, http.MethodDelete, path(foreign), session, nil), http.StatusNotFound, deviceNotFoundRevoke) wantBody(t, env.call(t, http.MethodDelete, path(999999999), session, nil), http.StatusNotFound, deviceNotFoundRevoke) wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/0", session, nil), http.StatusNotFound, deviceNotFoundRevoke) for _, bad := range []string{"abc", "1a", "-1"} { // The digit constraint keeps these from the handler, which // would answer 401 without a session. rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+bad, "", nil) if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { t.Fatalf("DELETE /devices/%s = %d %s", bad, rec.Code, rec.Body.String()) } } env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokA, nil) wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody) wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody) env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tokA, nil) // A device may revoke itself; a revoked device cannot list. wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", tokA, nil), http.StatusUnauthorized, deviceUnauthorized) var foreignRow models.DeviceAuthSession if err := env.gdb.Where("id = ?", foreign).Take(&foreignRow).Error; err != nil || foreignRow.Status != models.DeviceStatusUsed { t.Fatalf("the foreign device = %+v %v", foreignRow, err) } // A used row without a jti (a Winter import) is revoked too. envSetDevice(t, env, idC, map[string]any{"jwt_jti": nil}) wantBody(t, env.call(t, http.MethodDelete, path(idC), session, nil), http.StatusOK, deviceRevokedOKBody) var left []uint for _, d := range envDevices(t, env, session).Devices { left = append(left, d.ID) } if !slices.Equal(left, []uint{idD, idB}) { t.Fatalf("listed after revokes = %v", left) } env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) }) } // TestDeviceRevokeAfterRefresh pins D-12b through refresh: after one and // after two refreshes the row tracks the newest jti, a revoke signs out // every token of the lineage and refuses the next refresh, and the // blacklist entry lasts the whole refresh window. A refresh that fails // leaves the row as it was. func TestDeviceRevokeAfterRefresh(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) owner, session := envSession(t, env, "drr-owner") refresh := func(t *testing.T, token string) string { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil) var body struct { Token string `json:"token"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" { t.Fatalf("refresh %s: %v", rec.Body.String(), err) } return body.Token } jtiOf := func(t *testing.T, token string) string { t.Helper() _, _, _, jti, err := bouncer.VerifyClaims(token, sessionSecret) if err != nil { t.Fatal(err) } return jti } for _, n := range []int{1, 2} { t.Run(strconv.Itoa(n)+"_refreshes", func(t *testing.T) { first, _, id := envDevice(t, env, session, "Desk "+strconv.Itoa(n)) chain := []string{first} for range n { chain = append(chain, refresh(t, chain[len(chain)-1])) var row models.DeviceAuthSession if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil { t.Fatal(err) } if row.JWTJTI == nil || *row.JWTJTI != jtiOf(t, chain[len(chain)-1]) || row.Status != models.DeviceStatusUsed { t.Fatalf("row after refresh %d = %+v", len(chain)-1, row) } } latest := chain[len(chain)-1] env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", latest, nil) start := time.Now() wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody) for i, tok := range chain { if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", tok, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("token %d of the lineage still works after the revoke: %d", i, rec.Code) } } env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", latest, nil) var expires time.Time if err := env.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jtiOf(t, latest)).Scan(&expires).Error; err != nil { t.Fatal(err) } if window := start.Add(controllers.RefreshTTL(env.app)); expires.Before(window) { t.Fatalf("blacklist entry ends %s, before the refresh window %s", expires, window) } env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) }) } t.Run("failed_refresh_leaves_the_row", func(t *testing.T) { token, jti, id := envDevice(t, env, session, "Desk fail") var before models.DeviceAuthSession if err := env.gdb.Where("id = ?", id).Take(&before).Error; err != nil { t.Fatal(err) } // The device's jti in a token signed with another key. forged := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": jti, "prv": prvHash, "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), }) signed, err := forged.SignedString([]byte("another-secret-entirely-another-secret")) if err != nil { t.Fatal(err) } env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", signed, nil) // The device token itself, refused by the subject check once the // user is banned. banned, bannedSession := envSession(t, env, "drr-banned") bannedToken, bannedJTI, bannedID := envDevice(t, env, bannedSession, "Banned desk") env.seedThrottle(t, banned, "", true, nil) env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", bannedToken, nil) for _, c := range []struct { id uint jti string }{{id, jti}, {bannedID, bannedJTI}} { var row models.DeviceAuthSession if err := env.gdb.Where("id = ?", c.id).Take(&row).Error; err != nil { t.Fatal(err) } if row.JWTJTI == nil || *row.JWTJTI != c.jti || row.Status != models.DeviceStatusUsed { t.Fatalf("a failed refresh moved the row: %+v", row) } } if after := refresh(t, token); jtiOf(t, after) == jti { t.Fatal("the device token did not rotate") } }) } // TestDeviceTokensAtRest pins D-06 for device sessions: the token column is // the SHA-256 hex of the token, never the token; no column holds the token // or the session JWT the device received; the model hides token, // short_code and jwt_jti from JSON. func TestDeviceTokensAtRest(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) _, session := envSession(t, env, "dtr-owner") dev := envNewDevice(t, env) sum := sha256.Sum256([]byte(dev.Token)) row := envDeviceRow(t, env, dev.Token) if row.TokenHash != hex.EncodeToString(sum[:]) || row.TokenHash == dev.Token || len(row.TokenHash) != 64 { t.Fatalf("token column = %q", row.TokenHash) } rowJSON := func() string { t.Helper() var out string if err := env.gdb.Raw(`SELECT row_to_json(s)::text FROM golem15_user_device_auth_sessions s WHERE id = ?`, row.ID).Scan(&out).Error; err != nil { t.Fatal(err) } return out } if strings.Contains(rowJSON(), dev.Token) { t.Fatal("a column holds the device token") } wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) rec := envPoll(t, env, dev.Token) var polled struct { Data struct { Token string `json:"token"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" { t.Fatalf("hand-over %s", rec.Body.String()) } stored := rowJSON() if strings.Contains(stored, dev.Token) || strings.Contains(stored, polled.Data.Token) { t.Fatal("a column holds the device token or its session JWT") } raw, err := json.Marshal(envDeviceRow(t, env, dev.Token)) if err != nil { t.Fatal(err) } for _, secret := range []string{row.TokenHash, dev.ShortCode, `"JWTJTI"`, `"TokenHash"`, `"ShortCode"`} { if strings.Contains(string(raw), secret) { t.Fatalf("the model's JSON carries %s: %s", secret, raw) } } }