The warning was purely a token-expiry countdown with no concept of user activity, so it interrupted people who had been clicking through questions the whole time. AuthState now tracks real input activity globally and silently refreshes the session in the background when the user has interacted recently, only surfacing the "Stay logged in?" dialog for sessions that are genuinely idle. A failed background refresh still surfaces the dialog rather than silently logging the user out.
120 lines
6.2 KiB
GDScript
120 lines
6.2 KiB
GDScript
extends SceneTree
|
|
|
|
var failures := 0
|
|
var auth
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
auth = load("res://scripts/autoload/auth_state.gd").new()
|
|
root.add_child(auth)
|
|
await process_frame
|
|
_test_expiration_parsing()
|
|
_test_warning_and_expiration()
|
|
_test_active_user_silently_refreshes_instead_of_warning()
|
|
_test_refresh_preserves_identity()
|
|
_test_refresh_in_flight_has_expiry_grace()
|
|
_test_stale_responses_cannot_restore_or_end_session()
|
|
auth.queue_free()
|
|
await process_frame
|
|
quit(1 if failures > 0 else 0)
|
|
|
|
|
|
func _test_expiration_parsing() -> void:
|
|
var iso_expiration: float = float(auth._parse_expiration("2030-01-02T03:04:05Z"))
|
|
var expected_iso := float(Time.get_unix_time_from_datetime_string("2030-01-02T03:04:05"))
|
|
_assert(is_equal_approx(iso_expiration, expected_iso), "session expiry should parse the Auth API ISO timestamp")
|
|
var jwt_expiration := 1893456000
|
|
var payload: String = Marshalls.raw_to_base64(JSON.stringify({"exp": jwt_expiration}).to_utf8_buffer())
|
|
payload = payload.replace("+", "-").replace("/", "_").trim_suffix("=")
|
|
var token := "header.%s.signature" % payload
|
|
_assert(int(auth._parse_expiration("", token)) == jwt_expiration, "session expiry should fall back to the JWT exp claim")
|
|
|
|
|
|
func _test_warning_and_expiration() -> void:
|
|
var observed := {"warning_seconds": -1, "expiration_reason": ""}
|
|
var warning_callback := func(seconds: int): observed["warning_seconds"] = seconds
|
|
var expiration_callback := func(reason: String): observed["expiration_reason"] = reason
|
|
auth.session_warning.connect(warning_callback)
|
|
auth.session_expired.connect(expiration_callback)
|
|
auth.set_session("tester", "warning-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 90.0))
|
|
auth.last_activity_unix = Time.get_unix_time_from_system() - 3600.0
|
|
auth._evaluate_expiry()
|
|
_assert(int(observed["warning_seconds"]) > 0 and int(observed["warning_seconds"]) <= 90, "session should warn inside the two-minute renewal window")
|
|
auth.expiration_unix = Time.get_unix_time_from_system() - 1.0
|
|
auth._evaluate_expiry()
|
|
_assert(str(observed["expiration_reason"]) == "expired" and not auth.is_logged_in, "an unanswered warning should clear the session at expiry")
|
|
auth.session_warning.disconnect(warning_callback)
|
|
auth.session_expired.disconnect(expiration_callback)
|
|
|
|
|
|
func _test_active_user_silently_refreshes_instead_of_warning() -> void:
|
|
var observed := {"warning_seconds": -1}
|
|
var warning_callback := func(seconds: int): observed["warning_seconds"] = seconds
|
|
auth.session_warning.connect(warning_callback)
|
|
# Point at a local, always-refused port so the real refresh attempt this triggers fails
|
|
# fast and never touches the real network - the point of this test is that
|
|
# request_session_refresh() gets called at all, not that it succeeds.
|
|
auth.auth_base_url = "http://127.0.0.1:1"
|
|
auth.set_session("tester", "active-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 90.0))
|
|
auth.last_activity_unix = Time.get_unix_time_from_system()
|
|
auth._evaluate_expiry()
|
|
_assert(int(observed["warning_seconds"]) == -1 and not auth.warning_active, "an actively-used session should not show the expiry warning")
|
|
_assert(auth.refresh_in_progress, "an actively-used session nearing expiry should silently attempt a refresh instead of warning")
|
|
auth.session_warning.disconnect(warning_callback)
|
|
auth.refresh_in_progress = false
|
|
auth.auth_base_url = auth.DEFAULT_AUTH_BASE_URL
|
|
auth.clear_session()
|
|
|
|
|
|
func _test_refresh_preserves_identity() -> void:
|
|
var observed := {"refreshed": false}
|
|
var refreshed_callback := func(): observed["refreshed"] = true
|
|
auth.session_refreshed.connect(refreshed_callback)
|
|
auth.set_session("tester", "old-token", "persistent-refresh", "SUPER", str(Time.get_unix_time_from_system() + 60.0))
|
|
auth.refresh_in_progress = true
|
|
auth.refresh_revision = auth.session_revision
|
|
var next_expiration := Time.get_unix_time_from_system() + 900.0
|
|
var body := JSON.stringify({"accessToken": "new-token", "exp": next_expiration}).to_utf8_buffer()
|
|
auth._on_refresh_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body)
|
|
_assert(bool(observed["refreshed"]) and auth.access_token == "new-token", "successful renewal should install the replacement access token")
|
|
_assert(auth.refresh_token == "persistent-refresh" and auth.username == "tester" and auth.role == "SUPER", "renewal should preserve identity and the non-rotating refresh token")
|
|
_assert(not auth.warning_active and auth.expiration_unix > Time.get_unix_time_from_system(), "renewal should reset the warning window")
|
|
auth.session_refreshed.disconnect(refreshed_callback)
|
|
|
|
|
|
func _test_refresh_in_flight_has_expiry_grace() -> void:
|
|
auth.set_session("tester", "old-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 60.0))
|
|
auth.expiration_unix = Time.get_unix_time_from_system() - 1.0
|
|
auth.refresh_in_progress = true
|
|
auth._evaluate_expiry()
|
|
_assert(auth.is_logged_in, "a submitted renewal should be allowed to finish after the old access token expires")
|
|
_assert(not auth.can_send_authenticated_request(), "expired access tokens should not be used while renewal is in progress")
|
|
auth.refresh_in_progress = false
|
|
auth.clear_session()
|
|
|
|
|
|
func _test_stale_responses_cannot_restore_or_end_session() -> void:
|
|
auth.set_session("tester", "old-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 60.0))
|
|
auth.refresh_in_progress = true
|
|
auth.refresh_revision = auth.session_revision
|
|
auth.clear_session()
|
|
var body := JSON.stringify({"accessToken": "stale-token", "exp": Time.get_unix_time_from_system() + 900.0}).to_utf8_buffer()
|
|
auth._on_refresh_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body)
|
|
_assert(auth.access_token.is_empty(), "a delayed refresh response must not restore a logged-out session")
|
|
auth.set_session("tester", "current-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 900.0))
|
|
auth.handle_unauthorized("old-token")
|
|
_assert(auth.is_logged_in and auth.access_token == "current-token", "a stale 401 must not end a newer session")
|
|
auth.handle_unauthorized("current-token")
|
|
_assert(not auth.is_logged_in, "a 401 for the active token should end the invalid session")
|
|
|
|
|
|
func _assert(condition: bool, message: String) -> void:
|
|
if condition:
|
|
return
|
|
failures += 1
|
|
push_error(message)
|