ruby_raider 4.0.1 → 4.1.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. metadata +53 -2
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../../lib/utilities/flows/os/linux'
5
+
6
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
8
+ RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
9
+ # Plays systemctl --user and crontab: answers keyed by "systemctl verb" or "crontab -l"; never runs anything
10
+ let(:fake_runner_class) do
11
+ Class.new do
12
+ attr_reader :calls, :answers
13
+ attr_accessor :table
14
+
15
+ def initialize
16
+ @calls = []
17
+ @answers = {}
18
+ @table = nil
19
+ end
20
+
21
+ def call(*argv, stdin_data: nil)
22
+ @calls << argv
23
+ key = argv.first == 'crontab' ? "crontab #{argv[1]}" : "systemctl #{argv[2]}"
24
+ missing = key == 'crontab -l' && table.nil?
25
+ stderr, status = answers.fetch(key) { missing ? ['no crontab for raider', 1] : ['', 0] }
26
+ self.table = stdin_data if key == 'crontab -' && status.zero?
27
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: key == 'crontab -l' ? table.to_s : '', stderr: stderr, exitstatus: status)
28
+ end
29
+ end
30
+ end
31
+
32
+ let(:tmp) { File.realpath(Dir.mktmpdir('flows-linux')) }
33
+ let(:runner) { fake_runner_class.new }
34
+ let(:item) do
35
+ project = RubyRaider::Flows::Project.new(tmp)
36
+ RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: Time.utc(2026, 11, 9))
37
+ end
38
+ let(:cron_block) { "# raider-flow #{item.os_id}\n#{RubyRaider::Flows::Os::Cron.new.line(item)}\n" }
39
+ let(:no_bus) { ['Failed to connect to bus', 1] }
40
+
41
+ # The systemd units go to a temp home, never the real one
42
+ def pick = described_class.new(runner: runner, systemd: RubyRaider::Flows::Os::Systemd.new(runner: runner, home: tmp, config_home: nil))
43
+
44
+ after { FileUtils.rm_rf(tmp) }
45
+
46
+ it 'uses systemd when the user manager answers' do
47
+ linux = pick
48
+
49
+ expect([linux.label, linux.missed_runs_note, described_class.pick(runner).label]).to eq(['systemd', nil, 'systemd'])
50
+ expect(runner.calls.uniq).to eq([%w[systemctl --user show-environment]])
51
+ end
52
+
53
+ it 'falls back to cron when it does not, or systemctl is missing' do
54
+ [no_bus, ['Could not run systemctl', RubyRaider::Flows::Os::CommandRunner::MISSING]].each do |answer|
55
+ runner.answers['systemctl show-environment'] = answer
56
+
57
+ expect([pick.label, pick.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
58
+ end
59
+ end
60
+
61
+ it 'installs with the primary scheduler only' do
62
+ runner.answers['systemctl show-environment'] = no_bus
63
+
64
+ pick.install(item)
65
+
66
+ expect([runner.table, runner.calls.map(&:first).uniq]).to eq([cron_block, %w[systemctl crontab]])
67
+ end
68
+
69
+ describe '#remove' do
70
+ it 'also removes a cron entry when systemd is the primary' do
71
+ runner.table = "#{cron_block}1 * * * * a\n"
72
+
73
+ expect(pick.remove(item.os_id)).to be(true)
74
+ expect(runner.table).to eq("1 * * * * a\n")
75
+ end
76
+
77
+ it 'is false when neither has it' do
78
+ expect(pick.remove(item.os_id)).to be(false)
79
+ end
80
+
81
+ it 'skips cron when crontab -l does not work' do
82
+ runner.answers['crontab -l'] = ['crontab: not found', 127]
83
+
84
+ expect(pick.remove(item.os_id)).to be(false)
85
+ expect(runner.calls).not_to include(%w[crontab -])
86
+ end
87
+
88
+ it 'never asks systemd when cron is the primary, since systemd did not answer' do
89
+ runner.answers['systemctl show-environment'] = no_bus
90
+ runner.table = cron_block
91
+
92
+ expect(pick.remove(item.os_id)).to be(true)
93
+ expect(runner.calls.select { |argv| argv.first == 'systemctl' }).to eq([%w[systemctl --user show-environment]])
94
+ end
95
+ end
96
+
97
+ describe 'with a systemd timer, from a session where systemd does not answer' do
98
+ let(:units) { File.join(tmp, '.config', 'systemd', 'user') }
99
+
100
+ before do
101
+ runner.answers['systemctl show-environment'] = no_bus
102
+ FileUtils.mkdir_p(units)
103
+ File.write(File.join(units, "#{item.os_id}.timer"), 'x')
104
+ end
105
+
106
+ it 'is present' do
107
+ expect(pick.present?(item.os_id)).to be(true)
108
+ end
109
+
110
+ it 'removes the cron block, then raises so the schedule is kept, and leaves the unit' do
111
+ runner.table = "#{cron_block}1 * * * * a\n"
112
+
113
+ expect { pick.remove(item.os_id) }
114
+ .to raise_error(RubyRaider::Flows::Error, 'This schedule is a systemd timer; run raider flow unschedule report ' \
115
+ 'from a session where systemctl --user works')
116
+ expect([runner.table, File.exist?(File.join(units, "#{item.os_id}.timer"))]).to eq(["1 * * * * a\n", true])
117
+ end
118
+ end
119
+
120
+ it 'does not look for systemd unit files when systemd is the primary' do
121
+ FileUtils.mkdir_p(File.join(tmp, '.config', 'systemd', 'user'))
122
+ File.write(File.join(tmp, '.config', 'systemd', 'user', "#{item.os_id}.timer"), 'x')
123
+
124
+ expect(pick.remove(item.os_id)).to be(true)
125
+ end
126
+
127
+ describe '#present?' do
128
+ it 'is true when only the other scheduler has it' do
129
+ runner.answers['systemctl is-enabled'] = ['disabled', 1]
130
+ runner.table = cron_block
131
+
132
+ expect(pick.present?(item.os_id)).to be(true)
133
+ end
134
+
135
+ it 'is false when neither has it' do
136
+ runner.answers['systemctl is-enabled'] = ['disabled', 1]
137
+
138
+ expect(pick.present?(item.os_id)).to be(false)
139
+ end
140
+
141
+ it 'counts a failing other scheduler as not there' do
142
+ runner.answers['systemctl is-enabled'] = ['disabled', 1]
143
+ runner.answers['crontab -l'] = ['crontab: permission denied', 1]
144
+
145
+ expect(pick.present?(item.os_id)).to be(false)
146
+ end
147
+ end
148
+ end
149
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../../lib/utilities/flows/os/systemd'
5
+
6
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
+ # The fake runner, temp home and project folders sit next to the adapter under test
8
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
9
+ RSpec.describe RubyRaider::Flows::Os::Systemd do # rubocop:disable RSpec/FilePath
10
+ # Records every command and answers from a table keyed by the systemctl verb; never runs anything
11
+ let(:fake_runner_class) do
12
+ Class.new do
13
+ attr_reader :calls, :answers
14
+
15
+ def initialize
16
+ @calls = []
17
+ @answers = {}
18
+ end
19
+
20
+ def call(*argv, stdin_data: nil)
21
+ @calls << argv
22
+ @seen&.call(argv, stdin_data)
23
+ stderr, status = answers.fetch(argv[2], ['', 0])
24
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
25
+ end
26
+
27
+ def on_call(&block) = @seen = block
28
+ end
29
+ end
30
+
31
+ let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
32
+ let(:tmp) { File.realpath(Dir.mktmpdir('flows-systemd')) }
33
+ let(:home) { File.join(tmp, 'home') }
34
+ let(:project_dir) { File.join(tmp, 'plain') }
35
+ let(:runner) { fake_runner_class.new }
36
+ let(:systemd) { described_class.new(runner: runner, home: home, config_home: nil) }
37
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
38
+ let(:units) { File.join(home, '.config', 'systemd', 'user') }
39
+
40
+ def schedule(dir: project_dir, **options)
41
+ FileUtils.mkdir_p(dir)
42
+ project = RubyRaider::Flows::Project.new(File.realpath(dir))
43
+ RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
44
+ end
45
+
46
+ # systemd's specifier rule for a raw value: %% is %, any other % would be a specifier
47
+ def unspecify(text)
48
+ text.gsub(/%(.?)/m) do
49
+ raise ArgumentError, "specifier %#{Regexp.last_match(1)} in #{text.inspect}" unless Regexp.last_match(1) == '%'
50
+
51
+ '%'
52
+ end
53
+ end
54
+
55
+ # Splits a value the way systemd reads ExecStart= (dollars: true) or Environment= (dollars: false): words
56
+ # separated by spaces, each in double quotes with \\ and \" as the only C escapes, %% for %, and in ExecStart
57
+ # $$ for $. Strict: anything else raises, so a value that would be expanded or misread fails the spec.
58
+ # :reek:TooManyStatements
59
+ def systemd_words(text, dollars:)
60
+ words = []
61
+ rest = text.dup
62
+ until rest.empty?
63
+ raise ArgumentError, "unquoted word at #{rest.inspect}" unless rest.start_with?('"')
64
+
65
+ word = +''
66
+ index = 1
67
+ loop do
68
+ char = rest[index] or raise ArgumentError, 'unterminated quote'
69
+ index += 1
70
+ break if char == '"'
71
+
72
+ if char == '\\'
73
+ escaped = rest[index]
74
+ raise ArgumentError, "C escape \\#{escaped}" unless ['\\', '"'].include?(escaped)
75
+
76
+ word << escaped
77
+ index += 1
78
+ elsif char == '%' || (dollars && char == '$')
79
+ raise ArgumentError, "lone #{char}" unless rest[index] == char
80
+
81
+ word << char
82
+ index += 1
83
+ else
84
+ word << char
85
+ end
86
+ end
87
+ words << word
88
+ rest = rest[index..].sub(/\A /, '')
89
+ end
90
+ words
91
+ end
92
+
93
+ def value_of(text, key) = text.lines.map(&:chomp).select { |line| line.start_with?("#{key}=") }.map { |line| line.delete_prefix("#{key}=") }
94
+
95
+ def service_path(item) = File.join(units, "#{item.os_id}.service")
96
+
97
+ def timer_path(item) = File.join(units, "#{item.os_id}.timer")
98
+
99
+ after { FileUtils.rm_rf(tmp) }
100
+
101
+ it 'is called systemd in messages and has no missed runs note' do
102
+ expect([systemd.label, systemd.missed_runs_note]).to eq(['systemd', nil])
103
+ end
104
+
105
+ describe '#service' do
106
+ it 'writes the exact unit for a daily schedule' do
107
+ item = schedule
108
+ exec = item.argv.map { |arg| %("#{arg}") }.join(' ')
109
+ env = item.env.map { |key, value| %(Environment="#{key}=#{value}"\n) }.join
110
+
111
+ expect(systemd.service(item)).to eq(<<~UNIT)
112
+ [Unit]
113
+ Description=raider flow report
114
+
115
+ [Service]
116
+ Type=oneshot
117
+ WorkingDirectory=#{item.working_directory}
118
+ #{env.chomp}
119
+ ExecStart=#{exec}
120
+ StandardOutput=append:#{item.log_path}
121
+ StandardError=append:#{item.log_path}
122
+ UNIT
123
+ end
124
+
125
+ it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
126
+ allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/%h$x', 'GEM_PATH' => '/g/path')
127
+
128
+ lines = value_of(systemd.service(schedule), 'Environment').flat_map { |value| systemd_words(value, dollars: false) }
129
+
130
+ expect(lines.last(3)).to eq(['LANG=en_US.UTF-8', 'GEM_HOME=/g/%h$x', 'GEM_PATH=/g/path'])
131
+ end
132
+
133
+ describe 'with hostile values in an input and in the project path' do
134
+ let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), inputs: ["note=#{hostile}"]) }
135
+ let(:text) { systemd.service(item) }
136
+
137
+ it 'gives back the exact argv from ExecStart' do
138
+ argv = systemd_words(value_of(text, 'ExecStart').first, dollars: true)
139
+
140
+ expect(argv).to eq(item.argv)
141
+ expect(argv.last).to eq("note=#{hostile}")
142
+ expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
143
+ end
144
+
145
+ it 'gives back the exact paths and environment' do
146
+ env = value_of(text, 'Environment').flat_map { |value| systemd_words(value, dollars: false) }
147
+
148
+ expect([unspecify(value_of(text, 'WorkingDirectory').first), value_of(text, 'StandardOutput').map { |value| unspecify(value) }])
149
+ .to eq([item.working_directory, ["append:#{item.log_path}"]])
150
+ expect(env).to eq(item.env.map { |key, value| "#{key}=#{value}" })
151
+ end
152
+
153
+ it 'writes them only in escaped form' do
154
+ expect(text).to include('%%h', '$$(id)', '\\" \\\\ &')
155
+ expect(text.scan(/(?<!%)%(?!%)/)).to be_empty
156
+ expect(text.lines.length).to eq(systemd.service(schedule).lines.length)
157
+ end
158
+ end
159
+ end
160
+
161
+ describe '#timer' do
162
+ it 'writes the exact timer for a daily schedule' do
163
+ expect(systemd.timer(schedule)).to eq(<<~UNIT)
164
+ [Unit]
165
+ Description=raider flow report schedule
166
+
167
+ [Timer]
168
+ OnCalendar=*-*-* 08:00:00
169
+ Persistent=true
170
+
171
+ [Install]
172
+ WantedBy=timers.target
173
+ UNIT
174
+ end
175
+
176
+ it 'turns each rule into OnCalendar' do
177
+ rules = [schedule(every: 'hour', at: ':15'), schedule(every: 'weekday', at: '18:05'), schedule(every: 'week', on: 'sun', at: '07:30')]
178
+
179
+ expect(rules.map { |item| value_of(systemd.timer(item), 'OnCalendar').first })
180
+ .to eq(['*-*-* *:15:00', 'Mon..Fri *-*-* 18:05:00', 'Sun *-*-* 07:30:00'])
181
+ end
182
+ end
183
+
184
+ describe '#install' do
185
+ it 'writes both units 0644, reloads, then enables the timer' do
186
+ item = schedule
187
+
188
+ systemd.install(item)
189
+
190
+ expect(runner.calls).to eq([%w[systemctl --user daemon-reload], ['systemctl', '--user', 'enable', '--now', "#{item.os_id}.timer"]])
191
+ expect([File.read(service_path(item)), File.read(timer_path(item))]).to eq([systemd.service(item), systemd.timer(item)])
192
+ expect([service_path(item), timer_path(item)].map { |path| format('%o', File.stat(path).mode & 0o777) }).to eq(%w[644 644])
193
+ expect(Dir.children(units).sort).to eq([File.basename(service_path(item)), File.basename(timer_path(item))])
194
+ end
195
+
196
+ it 'uses an absolute XDG_CONFIG_HOME and ignores a relative one' do
197
+ item = schedule
198
+ config = File.join(tmp, 'xdg')
199
+ described_class.new(runner: runner, home: home, config_home: config).install(item)
200
+ described_class.new(runner: runner, home: home, config_home: 'relative').install(item)
201
+
202
+ expect([File.exist?(File.join(config, 'systemd', 'user', "#{item.os_id}.timer")), File.exist?(timer_path(item))]).to eq([true, true])
203
+ end
204
+
205
+ it 'replaces existing units' do
206
+ item = schedule
207
+ FileUtils.mkdir_p(units)
208
+ File.write(service_path(item), 'old')
209
+ File.write(timer_path(item), 'old')
210
+
211
+ systemd.install(item)
212
+
213
+ expect([File.read(service_path(item)), File.read(timer_path(item))]).to eq([systemd.service(item), systemd.timer(item)])
214
+ end
215
+
216
+ it 'removes both units, reloads and raises with the scheduler error when enable fails' do
217
+ item = schedule
218
+ runner.answers['enable'] = ["Failed to enable unit\e[2J\n", 1]
219
+
220
+ expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /systemd could not enable .*Failed to enable unit\?\[2J/)
221
+ expect(runner.calls.map { |argv| argv[2] }).to eq(%w[daemon-reload enable disable daemon-reload])
222
+ expect(runner.calls[2]).to eq(['systemctl', '--user', 'disable', "#{item.os_id}.timer"])
223
+ expect(Dir.children(units)).to be_empty
224
+ end
225
+
226
+ it 'removes both units when the first reload fails' do
227
+ item = schedule
228
+ runner.answers['daemon-reload'] = ['Failed to connect to bus', 1]
229
+
230
+ expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /Failed to connect to bus/)
231
+ expect(Dir.children(units)).to be_empty
232
+ end
233
+
234
+ it 'refuses a project path systemd can not keep, before writing anything' do
235
+ ['trailing ', 'tab\\'].each do |name|
236
+ item = schedule(dir: File.join(tmp, name))
237
+
238
+ expect { systemd.install(item) }.to raise_error(RubyRaider::Flows::Error, /systemd can not use the path/)
239
+ end
240
+ expect([runner.calls, File.exist?(units)]).to eq([[], false])
241
+ end
242
+
243
+ it 'leaves no temp file behind when writing one fails' do
244
+ allow(File).to receive(:open).and_call_original
245
+ allow(File).to receive(:open).with(/\.timer\..*\.tmp\z/, any_args).and_wrap_original do |original, path, *rest, **options|
246
+ original.call(path, *rest, **options) { raise Errno::ENOSPC }
247
+ end
248
+
249
+ expect { systemd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*No space left/)
250
+ expect([runner.calls, Dir.children(units)]).to eq([[], []])
251
+ end
252
+
253
+ it 'raises a flow error and runs nothing when it can not write the units' do
254
+ FileUtils.mkdir_p(File.dirname(units))
255
+ File.write(units, 'not a folder')
256
+
257
+ expect { systemd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*systemd/)
258
+ expect([runner.calls, File.read(units)]).to eq([[], 'not a folder'])
259
+ end
260
+ end
261
+
262
+ describe '#remove' do
263
+ it 'disables the timer, deletes both units and reloads' do
264
+ item = schedule
265
+ FileUtils.mkdir_p(units)
266
+ File.write(service_path(item), 'x')
267
+ File.write(timer_path(item), 'x')
268
+
269
+ expect(systemd.remove(item.os_id)).to be(true)
270
+ expect(runner.calls).to eq([['systemctl', '--user', 'disable', '--now', "#{item.os_id}.timer"], %w[systemctl --user daemon-reload]])
271
+ expect(Dir.children(units)).to be_empty
272
+ end
273
+
274
+ it 'is false when it was already gone, even if disable fails' do
275
+ runner.answers['disable'] = ['Unit file does not exist', 1]
276
+
277
+ expect(systemd.remove(schedule.os_id)).to be(false)
278
+ expect(runner.calls.map { |argv| argv[2] }).to eq(%w[disable daemon-reload])
279
+ end
280
+
281
+ it 'raises when a unit can not be deleted' do
282
+ item = schedule
283
+ allow(File).to receive(:unlink).and_raise(Errno::EACCES)
284
+
285
+ expect { systemd.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Could not remove .*Permission denied/)
286
+ end
287
+
288
+ it 'raises when the reload fails' do
289
+ runner.answers['daemon-reload'] = ['Failed to connect to bus', 1]
290
+
291
+ expect { systemd.remove(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /Failed to connect to bus/)
292
+ end
293
+
294
+ it 'refuses an os_id that is not a flow schedule id' do
295
+ expect { systemd.remove('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
296
+ expect(runner.calls).to be_empty
297
+ end
298
+ end
299
+
300
+ describe '#usable?' do
301
+ it 'asks the user manager for its environment' do
302
+ expect(systemd.usable?).to be(true)
303
+ runner.answers['show-environment'] = ['Failed to connect to bus', 1]
304
+ expect(systemd.usable?).to be(false)
305
+ expect(runner.calls).to eq([%w[systemctl --user show-environment]] * 2)
306
+ end
307
+ end
308
+
309
+ describe '#present?' do
310
+ it 'asks whether the timer is enabled' do
311
+ item = schedule
312
+
313
+ expect(systemd.present?(item.os_id)).to be(true)
314
+ expect(runner.calls).to eq([['systemctl', '--user', 'is-enabled', "#{item.os_id}.timer"]])
315
+ end
316
+
317
+ it 'is false when the timer is not enabled' do
318
+ runner.answers['is-enabled'] = ['disabled', 1]
319
+
320
+ expect(systemd.present?(schedule.os_id)).to be(false)
321
+ end
322
+
323
+ it 'refuses an os_id that is not a flow schedule id and runs nothing' do
324
+ expect { systemd.present?('x; rm') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
325
+ expect(runner.calls).to be_empty
326
+ end
327
+ end
328
+ end
329
+ # rubocop:enable RSpec/MultipleMemoizedHelpers