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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/generators/cucumber/templates/feature.tt +5 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +16 -0
- data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
- data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
- data/lib/generators/helper_generator.rb +7 -0
- data/lib/generators/rspec/templates/spec.tt +12 -0
- data/lib/generators/templates/common/git_ignore.tt +4 -1
- data/lib/generators/templates/common/partials/web_config.tt +4 -1
- data/lib/generators/templates/common/read_me.tt +24 -0
- data/lib/generators/templates/helpers/session_state.tt +206 -0
- data/lib/generators/templates/helpers/spec_helper.tt +20 -0
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/spec/integration/content/azure_content_spec.rb +4 -0
- data/spec/integration/content/ci_content_spec.rb +11 -0
- data/spec/integration/content/common_content_spec.rb +27 -0
- data/spec/integration/content/config_content_spec.rb +18 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
- data/spec/integration/content/helper_content_spec.rb +32 -0
- data/spec/integration/content/readme_content_spec.rb +28 -0
- data/spec/integration/content/syntax_validation_spec.rb +15 -0
- data/spec/integration/content/test_content_spec.rb +25 -0
- data/spec/integration/end_to_end_spec.rb +23 -0
- data/spec/integration/generators/helpers_generator_spec.rb +22 -0
- data/spec/integration/helpers/session_state_spec.rb +428 -0
- 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
|