From b41556853c31fa3a633fd520cc057dc0811dff5f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 17 Aug 2026 19:34:18 +1200 Subject: [PATCH] Make session timing controllable. Assisted-By: devx/cbdeae41-9308-4071-ad53-58cd92db2946 --- lib/utopia/session/lazy_hash.rb | 12 +++++++++--- lib/utopia/session/middleware.rb | 22 +++++++++++----------- test/utopia/session.rb | 26 ++++++++++++++++++++------ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/lib/utopia/session/lazy_hash.rb b/lib/utopia/session/lazy_hash.rb index f4c90887..e58f36ae 100644 --- a/lib/utopia/session/lazy_hash.rb +++ b/lib/utopia/session/lazy_hash.rb @@ -62,6 +62,12 @@ def changed? @changed end + # The current time for session expiry and persistence. + # @returns [Time] The current time in UTC. + def now + Time.now.utc + end + # Persist the session values if they have changed or require updating. # @parameter timeout [Numeric | Nil] The maximum age before an update is required. # @yields {|values, updated_at| ...} The loaded values and their update time. @@ -70,7 +76,7 @@ def persist(timeout = nil) return unless needs_update?(timeout) values = load! - updated_at = values[:updated_at] = Time.now.utc + updated_at = values[:updated_at] = now result = yield(values, updated_at) @changed = false @@ -94,7 +100,7 @@ def needs_update?(timeout = nil) # We want to be careful here and not call load! which isn't cheap operation. if timeout and @values and updated_at = @values[:updated_at] # If the last update was too long ago, we need update: - return true if updated_at < (Time.now - timeout) + return true if updated_at < (now - timeout) end return false @@ -104,7 +110,7 @@ def needs_update?(timeout = nil) # Load and return the underlying values. def load! - @values ||= @loader.call + @values ||= @loader.call(now) end end end diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index d5be1110..c9f38888 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -143,8 +143,8 @@ def normalize_same_site(same_site) end def prepare_session(request) - LazyHash.new do - self.load_session_values(request) + LazyHash.new do |now| + self.load_session_values(request, now) end end @@ -155,22 +155,22 @@ def update_session(session_hash, headers) end # Constructs a valid session for the given request. These fields must match as per the checks performed in `valid_session?`: - def build_initial_session(request) + def build_initial_session(request, now) { user_agent: request.user_agent, - created_at: Time.now.utc, - updated_at: Time.now.utc, + created_at: now, + updated_at: now, } end # Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked. # @return hash of values. - def load_session_values(request) + def load_session_values(request, now) # Decrypt the data from the user if possible: if data = request.cookies[@cookie_name] begin if values = decrypt(data) - validate_session!(request, values) + validate_session!(request, values, now) return values end @@ -180,16 +180,16 @@ def load_session_values(request) end # If we couldn't create a session - return build_initial_session(request) + return build_initial_session(request, now) end - def validate_session!(request, values) + def validate_session!(request, values, now) if values[:user_agent] != request.user_agent raise PayloadError, "Invalid session because supplied user agent #{request.user_agent.inspect} does not match session user agent #{values[:user_agent].inspect}!" end if expires_at = expires(values[:updated_at]) - if expires_at < Time.now.utc + if expires_at < now raise PayloadError, "Expired session cookie, user agent submitted a cookie that should have expired at #{expires_at}." end end @@ -197,7 +197,7 @@ def validate_session!(request, values) return true end - def expires(updated_at=Time.now.utc) + def expires(updated_at) if @expires_after return updated_at + @expires_after end diff --git a/test/utopia/session.rb b/test/utopia/session.rb index 449f9c65..61f3867f 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -11,8 +11,14 @@ describe Utopia::Session do include Sus::Fixtures::Protocol::HTTP::MiddlewareContext + let(:clock) {Struct.new(:now).new(Time.now.utc)} + let(:middleware) do Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| + mock(request.session) do |session| + session.replace(:now){clock.now} + end + case request.path.to_s when "/login" request.session["login"] = "true" @@ -75,8 +81,7 @@ client.get "/session-set?key=foo&value=bar" expect(last_response.headers).to have_keys("set-cookie") - # Sleep more than update_timeout - sleep 2 + clock.now += 2 client.get "/session-set?key=foo&value=bar" expect(last_response.headers).to have_keys("set-cookie") @@ -272,8 +277,14 @@ def corrupt_payload(data, offset) describe Utopia::Session do include Sus::Fixtures::Protocol::HTTP::MiddlewareContext + let(:clock) {Struct.new(:now).new(Time.now.utc)} + let(:middleware) do Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| + mock(request.session) do |session| + session.replace(:now){clock.now} + end + case request.path.to_s when "/session-set" request.session[request.query_parameters["key"].to_sym] = request.query_parameters["value"] @@ -316,7 +327,7 @@ def before it "should fail if expired cookie is sent with the request" do session_cookie = last_response.headers["set-cookie"].first.split(";")[0] - sleep 6 # sleep longer than the session timeout + clock.now += 6 client.set_cookie session_cookie client.get "/session-get?key=foo" @@ -369,9 +380,11 @@ def before end it "should need to be reloaded if old" do + current_time = Time.now.utc hash = Utopia::Session::LazyHash.new do - {updated_at: Time.now - 3700} + {updated_at: current_time - 3700} end + expect(hash).to receive(:now).and_return(current_time) expect(hash.needs_update?(3600)).to be == false @@ -401,16 +414,17 @@ def before end it "should persist changed values" do + current_time = Time.now.utc hash = Utopia::Session::LazyHash.new do {a: 10} end + expect(hash).to receive(:now).and_return(current_time) hash[:a] = 20 - persisted_at = Time.now.utc result = hash.persist do |values, updated_at| expect(values[:a]).to be == 20 - expect(updated_at).to be >= persisted_at + expect(updated_at).to be_equal(current_time) expect(values[:updated_at]).to be_equal(updated_at) :complete