ruby_raider 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/lib/generators/cucumber/templates/feature.tt +5 -0
  4. data/lib/generators/cucumber/templates/partials/selenium_env.tt +16 -0
  5. data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
  6. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  7. data/lib/generators/helper_generator.rb +7 -0
  8. data/lib/generators/rspec/templates/spec.tt +12 -0
  9. data/lib/generators/templates/common/git_ignore.tt +4 -1
  10. data/lib/generators/templates/common/partials/web_config.tt +4 -1
  11. data/lib/generators/templates/common/read_me.tt +24 -0
  12. data/lib/generators/templates/helpers/session_state.tt +206 -0
  13. data/lib/generators/templates/helpers/spec_helper.tt +20 -0
  14. data/lib/version +1 -1
  15. data/ruby_raider.gemspec +1 -0
  16. data/spec/integration/content/azure_content_spec.rb +4 -0
  17. data/spec/integration/content/ci_content_spec.rb +11 -0
  18. data/spec/integration/content/common_content_spec.rb +27 -0
  19. data/spec/integration/content/config_content_spec.rb +18 -0
  20. data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
  21. data/spec/integration/content/helper_content_spec.rb +32 -0
  22. data/spec/integration/content/readme_content_spec.rb +28 -0
  23. data/spec/integration/content/syntax_validation_spec.rb +15 -0
  24. data/spec/integration/content/test_content_spec.rb +25 -0
  25. data/spec/integration/end_to_end_spec.rb +23 -0
  26. data/spec/integration/generators/helpers_generator_spec.rb +22 -0
  27. data/spec/integration/helpers/session_state_spec.rb +428 -0
  28. metadata +18 -2
@@ -112,4 +112,32 @@ describe 'Generated Readme content' do
112
112
  end
113
113
  end
114
114
  end
115
+
116
+ FRAMEWORKS.product(AUTOMATION_TYPES).each do |framework, automation|
117
+ context "with #{framework} and #{automation}, the login reuse section" do
118
+ subject(:readme) { read_generated("#{framework}_#{automation}", 'Readme.md') }
119
+
120
+ if %w[selenium watir].include?(automation)
121
+ it 'documents the tags, other roles, max_age, the env switch and the secrets warning' do
122
+ section = readme[/^## Reusing the login\n.*?(?=^##)/m].gsub(/\s+/, ' ')
123
+ expect(section).to include('`:logged_in`', '`@logged_in`', "SessionState.new(driver, name: 'admin')",
124
+ 'browser.driver', 'login_state', 'max_age', 'RAIDER_LOGIN_STATE=off',
125
+ 'RAIDER_LOGIN_STATE=refresh', '.raider/auth/', 'git-ignored',
126
+ 'live session tokens', 'must not be committed, cached or uploaded')
127
+ end
128
+
129
+ it 'is its own ## section, straight after the Quality gate and before any CI section' do
130
+ heading = readme.index("\n## Reusing the login\n")
131
+ gate = readme.index("\n### Quality gate\n")
132
+ expect(gate).to be < heading
133
+ expect(readme[(gate + 1)...heading]).not_to match(/\n##/)
134
+ expect(readme[(heading + 1)..][/\n## .*/]).to be_nil.or match(/\A\n## (CI|Azure Pipelines)\z/)
135
+ end
136
+ else
137
+ it 'does not mention login reuse' do
138
+ expect(readme).not_to include('Reusing the login', 'RAIDER_LOGIN_STATE', '.raider/auth/')
139
+ end
140
+ end
141
+ end
142
+ end
115
143
  end
@@ -24,6 +24,21 @@ describe 'Ruby syntax validation' do
24
24
  expect(content).to have_frozen_string_literal
25
25
  end
26
26
  end
27
+
28
+ login_reuse_files = %w[helpers/session_state.rb]
29
+ login_reuse_files += if framework == 'rspec'
30
+ %w[helpers/spec_helper.rb]
31
+ else
32
+ %w[features/support/env.rb features/step_definitions/login_steps.rb]
33
+ end
34
+
35
+ login_reuse_files.each do |relative|
36
+ it "#{relative} is generated as valid Ruby with frozen_string_literal" do
37
+ content = File.read(File.join(project_name, relative))
38
+ expect(content).to have_valid_ruby_syntax
39
+ expect(content).to have_frozen_string_literal
40
+ end
41
+ end
27
42
  end
28
43
  end
29
44
  end
@@ -116,18 +116,41 @@ describe 'Test file content' do
116
116
  end
117
117
  end
118
118
 
119
+ shared_examples 'logged in example' do |project_name|
120
+ it 'adds a :logged_in example that reuses the saved login and keeps the login example' do
121
+ spec = read_generated(project_name, 'spec/login_page_spec.rb')
122
+ expect(spec).to include("describe 'Account', :logged_in", 'opens the account page with the saved login',
123
+ "describe 'Login' do", 'can successfully log in')
124
+ end
125
+ end
126
+
127
+ shared_examples 'logged in scenario' do |project_name|
128
+ it 'adds a @logged_in scenario and keeps the login scenario' do
129
+ feature = read_generated(project_name, 'features/login.feature')
130
+ expect(feature).to include("@logged_in\nScenario: A logged-in user opens the account page",
131
+ "Given I'm a registered user\n", 'Scenario: A user can login')
132
+ end
133
+
134
+ it 'defines the step that picks the user without logging in' do
135
+ steps = read_generated(project_name, 'features/step_definitions/login_steps.rb')
136
+ expect(steps).to include(%q(Given("I'm a {user}") do |user|))
137
+ end
138
+ end
139
+
119
140
  # --- RSpec contexts ---
120
141
 
121
142
  context 'with rspec and selenium' do
122
143
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
123
144
  include_examples 'valid rspec spec', name
124
145
  include_examples 'selenium rspec spec', name
146
+ include_examples 'logged in example', name
125
147
  end
126
148
 
127
149
  context 'with rspec and watir' do
128
150
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
129
151
  include_examples 'valid rspec spec', name
130
152
  include_examples 'watir rspec spec', name
153
+ include_examples 'logged in example', name
131
154
  end
132
155
 
133
156
  # --- Cucumber contexts ---
@@ -138,6 +161,7 @@ describe 'Test file content' do
138
161
  include_examples 'valid cucumber env', name
139
162
  include_examples 'valid cucumber world', name
140
163
  include_examples 'valid cucumber config', name
164
+ include_examples 'logged in scenario', name
141
165
  end
142
166
 
143
167
  context 'with cucumber and watir' do
@@ -146,5 +170,6 @@ describe 'Test file content' do
146
170
  include_examples 'valid cucumber env', name
147
171
  include_examples 'valid cucumber world', name
148
172
  include_examples 'valid cucumber config', name
173
+ include_examples 'logged in scenario', name
149
174
  end
150
175
  end
@@ -269,6 +269,29 @@ describe 'End-to-End Project Generation and Execution' do
269
269
  context 'Web Automation Frameworks' do
270
270
  describe 'Selenium + RSpec' do
271
271
  include_examples 'executable rspec project', 'rspec_selenium'
272
+
273
+ it 'saves the login once and reuses it', :aggregate_failures, :slow do
274
+ skip 'Bundle install failed' unless bundle_install('rspec_selenium')
275
+
276
+ state_file = File.join('rspec_selenium', '.raider', 'auth', 'user.json')
277
+ FileUtils.rm_rf(File.join('rspec_selenium', '.raider'))
278
+ first = run_in_project('rspec_selenium', 'bundle exec rspec spec/', env: { 'HEADLESS' => 'true' })
279
+ failed = ->(run, index) { "Run #{index} failed.\nSTDOUT: #{run[:stdout]}\nSTDERR: #{run[:stderr]}" }
280
+ raise failed.call(first, 1) unless first[:exit_code].zero? # show run 1's output, not a missing state file
281
+
282
+ expect(File).to exist(state_file)
283
+ saved = JSON.parse(File.read(state_file))
284
+ second = run_in_project('rspec_selenium', 'bundle exec rspec spec/', env: { 'HEADLESS' => 'true' })
285
+ expect(second[:exit_code]).to eq(0), failed.call(second, 2)
286
+ expect(File.stat(state_file).mode & 0o777).to eq(0o600)
287
+ expect(saved).to include('format' => 1, 'origin' => 'https://raider-test-site.onrender.com')
288
+ expect(JSON.parse(File.read(state_file))['saved_at']).to eq(saved['saved_at'])
289
+ cookie_values = saved['cookies'].map { |cookie| cookie['value'].to_s }
290
+ expect(cookie_values.reject(&:empty?)).not_to be_empty
291
+ output = [first, second].map { |run| run[:stdout] + run[:stderr] }.join
292
+ # Short values like "1" or "en" can appear in any output; a leaked token is long.
293
+ cookie_values.select { |value| value.length >= 8 }.each { |value| expect(output).not_to include(value) }
294
+ end
272
295
  end
273
296
 
274
297
  describe 'Watir + RSpec' do
@@ -10,6 +10,18 @@ describe HelpersGenerator do
10
10
  end
11
11
  end
12
12
 
13
+ shared_examples 'creates a session state helper' do |name|
14
+ it 'creates a session state helper file' do
15
+ expect(File).to exist("#{name}/helpers/session_state.rb")
16
+ end
17
+ end
18
+
19
+ shared_examples 'skips the session state helper' do |name|
20
+ it 'does not create a session state helper file' do
21
+ expect(File).not_to exist("#{name}/helpers/session_state.rb")
22
+ end
23
+ end
24
+
13
25
  shared_examples 'creates selenium helpers' do |name|
14
26
  it 'creates a driver helper file' do
15
27
  expect(File).to exist("#{name}/helpers/driver_helper.rb")
@@ -42,54 +54,63 @@ describe HelpersGenerator do
42
54
 
43
55
  context 'with rspec and selenium' do
44
56
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
57
+ include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
45
58
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
46
59
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
47
60
  end
48
61
 
49
62
  context 'with rspec and watir' do
50
63
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
64
+ include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
51
65
  include_examples 'creates watir helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
52
66
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
53
67
  end
54
68
 
55
69
  context 'with cucumber and selenium' do
56
70
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
71
+ include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
57
72
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
58
73
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
59
74
  end
60
75
 
61
76
  context 'with cucumber and watir' do
62
77
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
78
+ include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
63
79
  include_examples 'creates watir helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
64
80
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
65
81
  end
66
82
 
67
83
  context 'with rspec and appium android' do
68
84
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
85
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
69
86
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
70
87
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
71
88
  end
72
89
 
73
90
  context 'with rspec and appium ios' do
74
91
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
92
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
75
93
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
76
94
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
77
95
  end
78
96
 
79
97
  context 'with cucumber and appium android' do
80
98
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
99
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
81
100
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
82
101
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
83
102
  end
84
103
 
85
104
  context 'with cucumber and appium ios' do
86
105
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
106
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
87
107
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
88
108
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
89
109
  end
90
110
 
91
111
  context 'with rspec and appium cross platform' do
92
112
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
113
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
93
114
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
94
115
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
95
116
  include_examples 'creates cross platform helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -97,6 +118,7 @@ describe HelpersGenerator do
97
118
 
98
119
  context 'with cucumber and appium cross platform' do
99
120
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
121
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
100
122
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
101
123
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
102
124
  include_examples 'creates cross platform helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+ require 'selenium-webdriver'
6
+ require 'time'
7
+ require 'tmpdir'
8
+ require 'yaml'
9
+
10
+ module SessionStateSpec
11
+ TEMPLATE = File.expand_path('../../../lib/generators/templates/helpers/session_state.tt', __dir__)
12
+
13
+ # Renders the template and loads it from a temp file that is removed straight away.
14
+ def self.load_template
15
+ Dir.mktmpdir('session_state_tt') do |dir|
16
+ file = File.join(dir, 'session_state.rb')
17
+ File.write(file, ERB.new(File.read(TEMPLATE), trim_mode: '-').result)
18
+ load file
19
+ end
20
+ end
21
+ end
22
+ SessionStateSpec.load_template
23
+
24
+ module SessionStateSpec
25
+ # Implements exactly the driver calls SessionState uses and records them in order.
26
+ class FakeDriver
27
+ ORIGIN_URL = 'https://raider-test-site.onrender.com/account'
28
+
29
+ def initialize(cookies: [], local: {}, session: {}, reject: nil, url: ORIGIN_URL)
30
+ @reject = reject
31
+ @page = { cookies:, url: }
32
+ @storage = { 'localStorage' => local, 'sessionStorage' => session }
33
+ @log = { calls: [], added: [] }
34
+ end
35
+
36
+ def calls = @log[:calls]
37
+ def added = @log[:added]
38
+ def local = @storage['localStorage']
39
+ def session = @storage['sessionStorage']
40
+
41
+ def manage = self
42
+ def navigate = self
43
+ def all_cookies = @page[:cookies]
44
+ def current_url = @page[:url]
45
+ def refresh = calls << :refresh
46
+ def delete_all_cookies = calls << :delete_all_cookies
47
+
48
+ def add_cookie(cookie)
49
+ raise 'rejected' if @reject&.call(cookie)
50
+
51
+ calls << :add_cookie
52
+ added << cookie
53
+ end
54
+
55
+ def to(url)
56
+ @page[:url] = url
57
+ calls << [:to, url]
58
+ end
59
+
60
+ def execute_script(_script, *args)
61
+ kind, values = args
62
+ return @storage[kind] if values.nil?
63
+
64
+ calls << [:write, kind]
65
+ @storage[kind] = values
66
+ end
67
+ end
68
+
69
+ # The browser end of a real Selenium::WebDriver::Manager: records the wire payloads it receives.
70
+ class FakeBridge
71
+ def initialize(log, reject)
72
+ @log = log
73
+ @reject = reject
74
+ end
75
+
76
+ def add_cookie(cookie)
77
+ raise Selenium::WebDriver::Error::InvalidArgumentError, 'rejected' if @reject&.call(cookie)
78
+
79
+ @log[:calls] << :add_cookie
80
+ @log[:added] << cookie
81
+ end
82
+
83
+ def delete_all_cookies = @log[:calls] << :delete_all_cookies
84
+ def cookies = []
85
+ end
86
+
87
+ # A FakeDriver whose manage is the real Selenium Manager, so its key translation is under test.
88
+ class ManagedDriver < FakeDriver
89
+ def manage
90
+ @manage ||= Selenium::WebDriver::Manager.new(FakeBridge.new(@log, @reject))
91
+ end
92
+ end
93
+ end
94
+
95
+ describe 'SessionState' do
96
+ let(:url) { 'https://raider-test-site.onrender.com/' }
97
+ let(:driver) { SessionStateSpec::FakeDriver.new(cookies:, local: { 'token' => 'abc' }, session: { 'tab' => '1' }) }
98
+ let(:state) { SessionState.new(driver) }
99
+ let(:config) { { 'url' => url } }
100
+
101
+ def origin = 'https://raider-test-site.onrender.com'
102
+ def state_file = File.join('.raider', 'auth', 'user.json')
103
+
104
+ def cookies
105
+ [{ name: 'sid', value: 's3cr3t', domain: 'raider-test-site.onrender.com', path: '/', expires: nil,
106
+ secure: true, http_only: true, same_site: 'Lax' }]
107
+ end
108
+
109
+ around do |example|
110
+ Dir.mktmpdir do |dir|
111
+ Dir.chdir(dir) do
112
+ FileUtils.mkdir_p('config')
113
+ example.run
114
+ end
115
+ end
116
+ end
117
+
118
+ before do
119
+ File.write('config/config.yml', YAML.dump(config))
120
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => nil))
121
+ end
122
+
123
+ def write_state(overrides = {})
124
+ FileUtils.mkdir_p(File.dirname(state_file))
125
+ data = { 'format' => 1, 'name' => 'user', 'origin' => origin, 'saved_at' => (Time.now.utc - 10).iso8601,
126
+ 'cookies' => [], 'local_storage' => {}, 'session_storage' => {} }.merge(overrides)
127
+ File.write(state_file, JSON.generate(data))
128
+ end
129
+
130
+ def saved
131
+ JSON.parse(File.read(state_file))
132
+ end
133
+
134
+ def saved_cookie(name, **attrs)
135
+ { 'name' => name, 'value' => 'v', 'domain' => 'raider-test-site.onrender.com', 'path' => '/',
136
+ 'expires' => nil, 'secure' => false, 'http_only' => false, 'same_site' => nil }.merge(attrs.transform_keys(&:to_s))
137
+ end
138
+
139
+ it 'rejects a name outside [a-z0-9_-]' do
140
+ expect { SessionState.new(driver, name: '../x') }.to raise_error(ArgumentError)
141
+ expect { SessionState.new(driver, name: 'Admin') }.to raise_error(ArgumentError)
142
+ end
143
+
144
+ it 'saves format 1 with origin, saved_at, cookies and both storages' do
145
+ state.save
146
+ expect(saved).to include('format' => 1, 'name' => 'user', 'origin' => origin,
147
+ 'local_storage' => { 'token' => 'abc' }, 'session_storage' => { 'tab' => '1' })
148
+ expect { Time.iso8601(saved['saved_at']) }.not_to raise_error
149
+ expect(saved['cookies'].first).to include('name' => 'sid', 'http_only' => true, 'secure' => true)
150
+ end
151
+
152
+ it 'writes the file 0600 in a 0700 directory, atomically' do
153
+ state.save
154
+ expect(File.stat(state_file).mode & 0o777).to eq(0o600)
155
+ expect(File.stat(File.dirname(state_file)).mode & 0o777).to eq(0o700)
156
+ expect(Dir.glob('.raider/auth/*.tmp')).to be_empty
157
+ end
158
+
159
+ context 'with a url that has a port and a path' do
160
+ let(:url) { 'https://x.test:8443/app/' }
161
+
162
+ it 'derives the origin without path or trailing slash' do
163
+ state.save
164
+ expect(saved['origin']).to eq('https://x.test:8443')
165
+ end
166
+ end
167
+
168
+ describe '#save on another origin' do
169
+ it 'goes back to the origin before reading when the login ended elsewhere' do
170
+ sso = SessionStateSpec::FakeDriver.new(url: 'https://sso.other.test/x')
171
+ SessionState.new(sso).save
172
+ expect(sso.calls).to eq([[:to, "#{origin}/"]])
173
+ end
174
+
175
+ it 'stays put when the login ended on the origin' do
176
+ state.save
177
+ expect(driver.calls).to be_empty
178
+ end
179
+ end
180
+
181
+ describe '#fresh?' do
182
+ it 'is false when the file is missing' do
183
+ expect(state.fresh?).to be(false)
184
+ end
185
+
186
+ it 'is true for a recent file of the same format and origin' do
187
+ write_state
188
+ expect(state.fresh?).to be(true)
189
+ end
190
+
191
+ it 'is false for another format, another origin or an old file' do
192
+ write_state('format' => 2)
193
+ expect(state.fresh?).to be(false)
194
+ write_state('origin' => 'https://other.test')
195
+ expect(state.fresh?).to be(false)
196
+ write_state('saved_at' => (Time.now.utc - 3601).iso8601)
197
+ expect(state.fresh?).to be(false)
198
+ end
199
+
200
+ context 'with login_state max_age 30' do
201
+ let(:config) { { 'url' => url, 'login_state' => { 'max_age' => 30 } } }
202
+
203
+ it 'is false for a file older than max_age' do
204
+ write_state('saved_at' => (Time.now.utc - 59).iso8601)
205
+ expect(state.fresh?).to be(false)
206
+ end
207
+ end
208
+ end
209
+
210
+ describe '#restore' do
211
+ let(:driver) { SessionStateSpec::ManagedDriver.new }
212
+
213
+ it 'restores live cookies for the origin and skips expired and foreign ones' do
214
+ write_state('cookies' => [
215
+ saved_cookie('session'),
216
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601),
217
+ saved_cookie('past', expires: (Time.now.utc - 1).iso8601),
218
+ saved_cookie('dotted', domain: '.raider-test-site.onrender.com'),
219
+ saved_cookie('foreign', domain: 'other.com')
220
+ ], 'local_storage' => { 'a' => 'b' })
221
+ warning = "[login_state] 2 cookies skipped (expired or another domain) from #{state_file}\n"
222
+ expect { expect(state.restore).to be(true) }.to output(warning).to_stderr
223
+ expect(driver.added.map { |c| c[:name] }).to eq(%w[session future dotted])
224
+ expect(driver.calls).to eq([[:to, "#{origin}/"], :delete_all_cookies, :add_cookie, :add_cookie, :add_cookie,
225
+ [:write, 'localStorage'], [:write, 'sessionStorage'], :refresh])
226
+ end
227
+
228
+ it 'hands Selenium the cookie keys it translates to the wire format' do
229
+ write_state('cookies' => [saved_cookie('session', same_site: 'Lax', http_only: true),
230
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601)])
231
+ expect(state.restore).to be(true)
232
+ expect(driver.added.first).to include(sameSite: 'Lax', httpOnly: true, secure: false)
233
+ expect(driver.added.flat_map(&:keys)).not_to include(:same_site, :http_only, :expires)
234
+ expect(driver.added.last[:expiry]).to be_a(Integer)
235
+ end
236
+
237
+ it 'returns false and warns for corrupt JSON' do
238
+ FileUtils.mkdir_p(File.dirname(state_file))
239
+ File.write(state_file, '{"format":')
240
+ expect { expect(state.restore).to be(false) }.to output(/\[login_state\].*user\.json/).to_stderr
241
+ end
242
+ end
243
+
244
+ it 'never prints cookie or storage values' do
245
+ leak = /s3cr3t|abc/
246
+ expect do
247
+ expect do
248
+ state.save
249
+ state.restore
250
+ write_state('cookies' => [saved_cookie('x', value: 's3cr3t')], 'local_storage' => { 'k' => 'abc' })
251
+ state.reuse(valid: -> { false }) { nil }
252
+ end.not_to output(leak).to_stdout
253
+ end.not_to output(leak).to_stderr
254
+ end
255
+
256
+ describe 'unsupported or unusable files' do
257
+ it 'warns once, naming the file, for another format' do
258
+ write_state('format' => 2)
259
+ expect { expect(state.restore).to be(false) }.to output(/\A\[login_state\] #{Regexp.escape(state_file)} has an unsupported format.*\n\z/).to_stderr
260
+ end
261
+
262
+ it 'stays silent for a missing, expired or other-origin file' do
263
+ expect do
264
+ state.fresh?
265
+ write_state('origin' => 'https://other.test')
266
+ state.fresh?
267
+ write_state('saved_at' => (Time.now.utc - 7200).iso8601)
268
+ state.fresh?
269
+ end.not_to output.to_stderr
270
+ end
271
+
272
+ it 'retries a cookie the browser rejects without sameSite' do
273
+ rejecting = SessionStateSpec::ManagedDriver.new(reject: ->(cookie) { cookie.key?(:sameSite) })
274
+ write_state('cookies' => [saved_cookie('a', same_site: 'None')])
275
+ expect(SessionState.new(rejecting).restore).to be(true)
276
+ expect(rejecting.added.map { |c| [c[:name], c.key?(:sameSite)] }).to eq([['a', false]])
277
+ end
278
+ end
279
+
280
+ describe '#reuse' do
281
+ def run_reuse(**options)
282
+ count = 0
283
+ silence { state.reuse(**options) { count += 1 } }
284
+ count
285
+ end
286
+
287
+ def silence
288
+ original = $stderr
289
+ $stderr = StringIO.new
290
+ yield
291
+ ensure
292
+ $stderr = original
293
+ end
294
+
295
+ def warnings
296
+ original = $stderr
297
+ $stderr = StringIO.new
298
+ yield
299
+ $stderr.string
300
+ ensure
301
+ $stderr = original
302
+ end
303
+
304
+ it 'logs in and saves when there is no file' do
305
+ expect(run_reuse).to eq(1)
306
+ expect(File).to exist(state_file)
307
+ expect(driver.calls).not_to include(:delete_all_cookies)
308
+ end
309
+
310
+ it 'clears the restored cookies and storage before the fallback login' do
311
+ managed = SessionStateSpec::ManagedDriver.new
312
+ write_state('cookies' => [saved_cookie('a')], 'local_storage' => { 'k' => 'v' })
313
+ silence { SessionState.new(managed).reuse(valid: -> { false }) { managed.calls << :login } }
314
+ after_refresh = managed.calls.drop(managed.calls.index(:refresh) + 1)
315
+ expect(after_refresh).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'], :login])
316
+ expect(managed.local).to eq({})
317
+ end
318
+
319
+ it 'clears a half-applied restore before the fallback login' do
320
+ broken = SessionStateSpec::ManagedDriver.new(reject: ->(_cookie) { true })
321
+ write_state('cookies' => [saved_cookie('a')])
322
+ silence { SessionState.new(broken).reuse { broken.calls << :login } }
323
+ expect(broken.calls.last(4)).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'],
324
+ :login])
325
+ end
326
+
327
+ it 'logs in without saving, with one warning, when the auth directory cannot be created' do
328
+ FileUtils.mkdir_p('.raider')
329
+ File.chmod(0o500, '.raider')
330
+ count = 0
331
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
332
+ expect(count).to eq(1)
333
+ expect(output.lines).to match([start_with('[login_state] ')])
334
+ ensure
335
+ File.chmod(0o700, '.raider')
336
+ end
337
+
338
+ it 'logs in without saving, with one warning, when the lock cannot be opened' do
339
+ FileUtils.mkdir_p(File.join('.raider', 'auth', 'user.lock'))
340
+ count = 0
341
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
342
+ expect(count).to eq(1)
343
+ expect(output.lines).to match([start_with('[login_state] ')])
344
+ expect(File).not_to exist(state_file)
345
+ end
346
+
347
+ it 'keeps the login, with one warning naming the file, when saving after it fails' do
348
+ allow(driver).to receive(:execute_script).and_raise(Selenium::WebDriver::Error::JavascriptError, 'abc')
349
+ count = 0
350
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
351
+ expect(count).to eq(1)
352
+ expect(output.lines).to match([include(state_file, 'JavascriptError')])
353
+ expect(output).not_to include('abc')
354
+ end
355
+
356
+ it 'restores and skips the login when fresh and valid' do
357
+ write_state
358
+ expect(run_reuse(valid: -> { true })).to eq(0)
359
+ expect(driver.calls).to include(:refresh)
360
+ end
361
+
362
+ it 'falls back once when valid returns false' do
363
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
364
+ before = Time.iso8601(saved['saved_at'])
365
+ expect(run_reuse(valid: -> { false })).to eq(1)
366
+ expect(Time.iso8601(saved['saved_at'])).to be > before
367
+ end
368
+
369
+ it 'falls back when the file is corrupt' do
370
+ FileUtils.mkdir_p(File.dirname(state_file))
371
+ File.write(state_file, '{"format":')
372
+ expect(run_reuse).to eq(1)
373
+ expect(saved['format']).to eq(1)
374
+ end
375
+
376
+ it 'falls back once, with one warning, when valid raises' do
377
+ write_state
378
+ count = 0
379
+ expect { state.reuse(valid: -> { raise 'boom' }) { count += 1 } }.to output(/\A\[login_state\] validity check failed \(RuntimeError\), logging in again\n\z/).to_stderr
380
+ expect(count).to eq(1)
381
+ end
382
+
383
+ it 'falls back once, with one warning, when restore raises' do
384
+ write_state('cookies' => [saved_cookie('a')])
385
+ broken = SessionStateSpec::FakeDriver.new(reject: ->(_cookie) { true })
386
+ count = 0
387
+ expect { SessionState.new(broken).reuse { count += 1 } }.to output(/\A\[login_state\] could not restore.*\n\z/).to_stderr
388
+ expect(count).to eq(1)
389
+ end
390
+
391
+ it 'warns once for another format and logs in' do
392
+ write_state('format' => 2)
393
+ count = 0
394
+ expect { state.reuse { count += 1 } }.to output(/\A\[login_state\] .*unsupported format.*\n\z/).to_stderr
395
+ expect(count).to eq(1)
396
+ end
397
+
398
+ it 'RAIDER_LOGIN_STATE=off logs in and does not touch the disk' do
399
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'off'))
400
+ expect(run_reuse).to eq(1)
401
+ expect(File).not_to exist('.raider')
402
+ end
403
+
404
+ it 'RAIDER_LOGIN_STATE=refresh logs in even when fresh' do
405
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
406
+ before = Time.iso8601(saved['saved_at'])
407
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'refresh'))
408
+ expect(run_reuse).to eq(1)
409
+ expect(Time.iso8601(saved['saved_at'])).to be > before
410
+ end
411
+
412
+ it 'serializes concurrent callers on the lock' do
413
+ count = 0
414
+ drivers = Array.new(2) { SessionStateSpec::FakeDriver.new(cookies:) }
415
+ threads = drivers.map do |fake|
416
+ Thread.new do
417
+ SessionState.new(fake).reuse do
418
+ sleep 0.2
419
+ count += 1
420
+ end
421
+ end
422
+ end
423
+ threads.each(&:join)
424
+ expect(count).to eq(1)
425
+ expect(drivers.count { |fake| fake.calls.include?(:refresh) }).to eq(1)
426
+ end
427
+ end
428
+ end