1 # Extracts the secrets for the XMPP account `account_name`.
2 def xmpp_account(account_name, required_options = [])
4 account = $config['Pidgin']['Accounts']['XMPP'][account_name]
5 check_keys = ['username', 'domain', 'password'] + required_options
6 check_keys.each do |key|
7 assert(account.key?(key))
8 assert_not_nil(account[key])
9 assert(!account[key].empty?)
11 rescue NoMethodError, Test::Unit::AssertionFailedError
13 "Your Pidgin:Accounts:XMPP:#{account} is incorrect or missing " \
14 "from your local configuration file (#{LOCAL_CONFIG_FILE}). " \
15 'See wiki/src/contribute/release_process/test/usage.mdwn for the format.'
21 def wait_and_focus(img, window, time = 10)
22 @screen.wait(img, time)
24 $vm.focus_window(window)
25 @screen.wait(img, time)
28 # This method should always fail (except with the option
29 # `return_shellcommand: true`) since we block Pidgin's D-Bus interface
31 def pidgin_dbus_call(method, *args, **opts)
32 opts[:user] = LIVE_USER
34 'im.pidgin.purple.PurpleService',
35 '/im/pidgin/purple/PurpleObject',
36 "im.pidgin.purple.PurpleInterface.#{method}",
41 # ... unless we re-enable it!
42 def pidgin_force_allowed_dbus_call(method, *args, **opts)
43 opts[:user] = LIVE_USER
44 policy_file = '/etc/dbus-1/session.d/im.pidgin.purple.PurpleService.conf'
45 $vm.execute_successfully("mv #{policy_file} #{policy_file}.disabled")
46 # From dbus-daemon(1): "Policy changes should take effect with SIGHUP"
47 # Note that HUP is asynchronous, so there is no guarantee whatsoever
48 # that the HUP will take effect before we do the dbus call. In
49 # practice, however, the delays imposed by using the remote shell is
50 # (in general) much larger than the processing time needed for
51 # handling signals, so they are in effect synchronous in our
53 $vm.execute_successfully("pkill -HUP -u #{opts[:user]} 'dbus-daemon'")
54 pidgin_dbus_call(method, *args, **opts)
56 $vm.execute_successfully("mv #{policy_file}.disabled #{policy_file}")
57 $vm.execute_successfully("pkill -HUP -u #{opts[:user]} 'dbus-daemon'")
60 def pidgin_account_connected?(account, prpl_protocol)
61 account_id = pidgin_force_allowed_dbus_call(
62 'PurpleAccountsFind', account, prpl_protocol
64 pidgin_force_allowed_dbus_call('PurpleAccountIsConnected', account_id) == 1
67 def click_mid_right_edge(pattern, **opts)
68 m = @screen.find(pattern, **opts)
69 @screen.click(m.x + m.w, m.y + m.h / 2)
72 When /^I create my XMPP account$/ do
73 account = xmpp_account('Tails_account')
74 @screen.click('PidginAccountManagerAddButton.png')
75 @screen.wait('PidginAddAccountWindow.png', 20)
76 click_mid_right_edge('PidginAddAccountProtocolLabel.png')
77 @screen.click('PidginAddAccountProtocolXMPP.png')
78 # We first wait for some field that is shown for XMPP but not the
79 # default (IRC) since we otherwise may decide where we click before
80 # the GUI has updated after switching protocol.
81 @screen.wait('PidginAddAccountXMPPDomain.png', 5)
82 click_mid_right_edge('PidginAddAccountXMPPUsername.png')
83 @screen.type(account['username'])
84 click_mid_right_edge('PidginAddAccountXMPPDomain.png')
85 @screen.type(account['domain'])
86 click_mid_right_edge('PidginAddAccountXMPPPassword.png')
87 @screen.type(account['password'])
88 @screen.click('PidginAddAccountXMPPRememberPassword.png')
89 if account['connect_server']
90 @screen.click('PidginAddAccountXMPPAdvancedTab.png')
91 click_mid_right_edge('PidginAddAccountXMPPConnectServer.png')
92 @screen.type(account['connect_server'])
94 @screen.click('PidginAddAccountXMPPAddButton.png')
97 Then /^Pidgin automatically enables my XMPP account$/ do
98 account = xmpp_account('Tails_account')
99 jid = account['username'] + '@' + account['domain']
101 pidgin_account_connected?(jid, 'prpl-jabber')
103 $vm.focus_window('Buddy List')
104 @screen.wait('PidginAvailableStatus.png', 60 * 3)
107 Given /^my XMPP friend goes online( and joins the multi-user chat)?$/ do |join_chat|
108 account = xmpp_account('Friend_account', ['otr_key'])
109 bot_opts = account.select { |k, _| ['connect_server'].include?(k) }
110 bot_opts['auto_join'] = [@chat_room_jid] if join_chat
111 @friend_name = account['username']
112 @chatbot = ChatBot.new(
113 account['username'] + '@' + account['domain'],
116 **(bot_opts.transform_keys(&:to_sym))
119 add_after_scenario_hook { @chatbot.stop }
120 $vm.focus_window('Buddy List')
121 @screen.wait('PidginFriendOnline.png', 60)
124 When /^I start a conversation with my friend$/ do
125 $vm.focus_window('Buddy List')
126 # Clicking the middle, bottom of this image should query our
127 # friend, given it's the only subscribed user that's online, which
129 r = @screen.find('PidginFriendOnline.png')
132 @screen.click(x, y, double: true)
133 # Since Pidgin sets the window name to the contact, we have no good
134 # way to identify the conversation window. Let's just look for the
136 @screen.wait('PidginConversationWindowMenuBar.png', 10)
139 And /^I say (.*) to my friend( in the multi-user chat)?$/ do |msg, multi_chat|
140 msg = 'ping' if msg == 'something'
142 $vm.focus_window(@chat_room_jid.split('@').first)
143 msg = @friend_name + ': ' + msg
145 $vm.focus_window(@friend_name)
147 @screen.type(msg, ['Return'])
150 Then /^I receive a response from my friend( in the multi-user chat)?$/ do |multi_chat|
152 $vm.focus_window(@chat_room_jid.split('@').first)
154 $vm.focus_window(@friend_name)
157 if @screen.exists('PidginServerMessage.png')
158 @screen.click('PidginDialogCloseButton.png')
160 @screen.find('PidginFriendExpectedAnswer.png')
164 When /^I start an OTR session with my friend$/ do
165 $vm.focus_window(@friend_name)
166 @screen.click('PidginConversationOTRMenu.png')
168 @screen.click('PidginOTRMenuStartSession.png')
171 Then /^Pidgin automatically generates an OTR key$/ do
172 @screen.wait('PidginOTRKeyGenPrompt.png', 30)
173 @screen.wait('PidginOTRKeyGenPromptDoneButton.png', 30).click
176 Then /^an OTR session was successfully started with my friend$/ do
177 $vm.focus_window(@friend_name)
178 @screen.wait('PidginConversationOTRUnverifiedSessionStarted.png', 10)
181 # The reason the chat must be empty is to guarantee that we don't mix
182 # up messages/events from other users with the ones we expect from the
184 When /^I join some empty multi-user chat$/ do
185 $vm.focus_window('Buddy List')
186 @screen.click('PidginBuddiesMenu.png')
187 @screen.wait('PidginBuddiesMenuJoinChat.png', 10).click
188 @screen.wait('PidginJoinChatWindow.png', 10).click
189 click_mid_right_edge('PidginJoinChatRoomLabel.png')
190 account = xmpp_account('Tails_account')
191 chat_room = if account.key?('chat_room') && \
192 !account['chat_room'].nil? && \
193 !account['chat_room'].empty?
196 random_alnum_string(10, 15)
198 @screen.type(chat_room)
200 # We will need the conference server later, when starting the bot.
201 click_mid_right_edge('PidginJoinChatServerLabel.png')
202 @screen.press('ctrl', 'a')
203 @screen.press('ctrl', 'c')
205 $vm.execute_successfully('xclip -o', user: LIVE_USER).stdout.chomp
206 @chat_room_jid = chat_room + '@' + conference_server
208 @screen.click('PidginJoinChatButton.png')
209 # The following will both make sure that the we joined the chat, and
210 # that it is empty. We'll also deal with the *potential* "Create New
211 # Room" prompt that Pidgin shows for some server configurations.
212 images = ['PidginCreateNewRoomPrompt.png',
213 'PidginChat1UserInRoom.png',]
214 image_found = @screen.wait_any(images, 30)[:found_pattern]
215 if image_found == 'PidginCreateNewRoomPrompt.png'
216 @screen.click('PidginCreateNewRoomAcceptDefaultsButton.png')
218 $vm.focus_window(@chat_room_jid)
219 @screen.wait('PidginChat1UserInRoom.png', 10)
222 # Since some servers save the scrollback, and sends it when joining,
223 # it's safer to clear it so we do not get false positives from old
224 # messages when looking for a particular response, or similar.
225 When /^I clear the multi-user chat's scrollback$/ do
226 $vm.focus_window(@chat_room_jid)
227 @screen.click('PidginConversationMenu.png')
228 @screen.wait('PidginConversationMenuClearScrollback.png', 10).click
231 Then /^I can see that my friend joined the multi-user chat$/ do
232 $vm.focus_window(@chat_room_jid)
233 @screen.wait('PidginChat2UsersInRoom.png', 60)
236 def configured_pidgin_accounts
238 xml = REXML::Document.new(
239 $vm.file_content("/home/#{LIVE_USER}/.purple/accounts.xml")
241 xml.elements.each('account/account') do |e|
242 account = e.elements['name'].text
243 account_name, network = account.split('@')
244 protocol = e.elements['protocol'].text
245 port = e.elements["settings/setting[@name='port']"].text
246 username_element = e.elements["settings/setting[@name='username']"]
247 realname_elemenet = e.elements["settings/setting[@name='realname']"]
248 nickname = username_element ? username_element.text : nil
249 real_name = realname_elemenet ? realname_elemenet.text : nil
250 accounts[network] = {
251 'name' => account_name,
252 'network' => network,
253 'protocol' => protocol,
255 'nickname' => nickname,
256 'real_name' => real_name,
263 def chan_image(account, channel, image)
265 'conference.riseup.net' => {
267 'conversation_tab' => 'PidginTailsConversationTab',
268 'welcome' => 'PidginTailsChannelWelcome',
272 images[account][channel][image] + '.png'
275 def default_chan(account)
277 'conference.riseup.net' => 'tails',
283 $vm.file_content("/home/#{LIVE_USER}/.purple/otr.private_key")
286 When /^I open Pidgin's account manager window$/ do
287 @screen.wait('PidginMenuAccounts.png', 20).click
288 @screen.wait('PidginMenuManageAccounts.png', 20).click
289 step "I see Pidgin's account manager window"
292 When /^I see Pidgin's account manager window$/ do
293 @screen.wait('PidginAccountWindow.png', 40)
296 When /^I close Pidgin's account manager window$/ do
297 @screen.wait('PidginDialogCloseButton.png', 10).click
300 When /^I close Pidgin$/ do
301 $vm.focus_window('Buddy List')
302 @screen.press('ctrl', 'q')
303 @screen.wait_vanish('PidginAvailableStatus.png', 10)
306 When /^I (de)?activate the "([^"]+)" Pidgin account$/ do |deactivate, account|
307 @screen.click("PidginAccount_#{account}.png")
308 @screen.type(['Left'], ['space'])
310 @screen.wait_vanish('PidginAccountEnabledCheckbox.png', 5)
312 # wait for the Pidgin to be connecting, otherwise sometimes the step
313 # that closes the account management dialog happens before the account
314 # is actually enabled
315 @screen.wait_any(['PidginConnecting.png', 'PidginAvailableStatus.png'], 5)
319 def deactivate_and_activate_pidgin_account(account)
320 debug_log("Deactivating and reactivating Pidgin account #{account}")
321 step "I open Pidgin's account manager window"
322 step "I deactivate the \"#{account}\" Pidgin account"
323 step "I close Pidgin's account manager window"
324 step "I open Pidgin's account manager window"
325 step "I activate the \"#{account}\" Pidgin account"
326 step "I close Pidgin's account manager window"
329 Then /^Pidgin successfully connects to the "([^"]+)" account$/ do |account|
330 expected_channel_entry = chan_image(account, default_chan(account), 'roster')
331 reconnect_button = 'PidginReconnect.png'
332 recovery_on_failure = proc do
333 if @screen.exists('PidginReconnect.png')
334 @screen.click('PidginReconnect.png')
336 deactivate_and_activate_pidgin_account(account)
339 retry_tor(recovery_on_failure) do
341 $vm.focus_window('Buddy List')
342 rescue ExecutionFailedInVM
343 # Sometimes focusing the window with xdotool will fail with the
344 # conversation window right on top of it. We'll try to close the
345 # conversation window. At worst, the test will still fail...
346 close_pidgin_conversation_window(account)
348 on_screen = @screen.wait_any([expected_channel_entry, reconnect_button],
350 unless on_screen == expected_channel_entry
351 raise "Connecting to account #{account} failed."
356 Then /^I can join the "([^"]+)" channel on "([^"]+)"$/ do |channel, account|
357 $vm.focus_window('Buddy List')
358 @screen.wait('PidginBuddiesMenu.png', 20).click
359 @screen.wait('PidginBuddiesMenuJoinChat.png', 10).click
360 @screen.wait('PidginJoinChatWindow.png', 10).click
361 click_mid_right_edge('PidginJoinChatRoomLabel.png')
362 @screen.type(channel)
363 @screen.click('PidginJoinChatButton.png')
364 @chat_room_jid = channel + '@' + account
365 $vm.focus_window(@chat_room_jid)
369 @screen.wait(chan_image(account, channel, 'conversation_tab'), 5).click
370 rescue FindFailed => e
371 # If the channel tab can't be found it could be because there were
372 # multiple connection attempts and the channel tab we want is off the
373 # screen. We'll try closing tabs until the one we want can be found.
374 @screen.press('ctrl', 'w')
379 @screen.wait(chan_image(account, channel, 'welcome'), 10)
382 Then /^I take note of the configured Pidgin accounts$/ do
383 @persistent_pidgin_accounts = configured_pidgin_accounts
386 Then /^I take note of the OTR key for Pidgin's "(?:[^"]+)" account$/ do
387 @persistent_pidgin_otr_keys = pidgin_otr_keys
390 Then /^Pidgin has the expected persistent accounts configured$/ do
391 current_accounts = configured_pidgin_accounts
393 current_accounts <=> @persistent_pidgin_accounts,
394 "Currently configured Pidgin accounts do not match the persistent ones:\n" \
395 "Current:\n#{current_accounts}\n" \
396 "Persistent:\n#{@persistent_pidgin_accounts}"
400 Then /^Pidgin has the expected persistent OTR keys$/ do
401 assert_equal(@persistent_pidgin_otr_keys, pidgin_otr_keys)
404 def pidgin_add_certificate_from(cert_file)
405 src = '/usr/share/ca-certificates/mozilla/' \
406 'Staat_der_Nederlanden_EV_Root_CA.crt'
407 # Here, we need a certificate that is not already in the NSS database
408 step "I copy \"#{src}\" to \"#{cert_file}\" as user \"amnesia\""
410 $vm.focus_window('Buddy List')
411 @screen.wait('PidginToolsMenu.png', 10).click
412 @screen.wait('PidginCertificatesMenuItem.png', 10).click
413 @screen.wait('PidginCertificateManagerDialog.png', 10)
414 @screen.wait('PidginCertificateAddButton.png', 10).click
416 @screen.wait('GtkFileChooserDesktopButton.png', 10).click
418 # The first time we're run, the file chooser opens in the Recent
419 # view, so we have to browse a directory before we can use the
420 # "Type file name" button. But on subsequent runs, the file
421 # chooser is already in the Desktop directory, so we don't need to
422 # do anything. Hence, this noop exception handler.
424 @screen.wait('GtkFileTypeFileNameButton.png', 10).click
425 @screen.press('alt', 'l') # "Location" field
426 @screen.type(cert_file, ['Return'])
429 Then /^I can add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
430 pidgin_add_certificate_from("#{cert_dir}/test.crt")
431 wait_and_focus('PidginCertificateAddHostnameDialog.png',
432 'Certificate Import', 10)
433 @screen.type('XXX test XXX', ['Return'])
434 wait_and_focus('PidginCertificateTestItem.png', 'Certificate Manager', 10)
437 Then /^I cannot add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
438 pidgin_add_certificate_from("#{cert_dir}/test.crt")
439 wait_and_focus('PidginCertificateImportFailed.png', 'Import Error', 10)
442 When /^I close Pidgin's certificate manager$/ do
443 wait_and_focus('PidginCertificateManagerDialog.png', 'Certificate Manager',
445 @screen.press('Escape')
446 # @screen.wait('PidginCertificateManagerClose.png', 10).click
447 @screen.wait_vanish('PidginCertificateManagerDialog.png', 10)
450 When /^I close Pidgin's certificate import failure dialog$/ do
451 @screen.press('Escape')
452 # @screen.wait('PidginCertificateManagerClose.png', 10).click
453 @screen.wait_vanish('PidginCertificateImportFailed.png', 10)
456 When /^I see the Tails GitLab URL$/ do
458 if @screen.exists('PidginServerMessage.png')
459 @screen.click('PidginDialogCloseButton.png')
462 @screen.find('PidginTailsGitLabUrl.png')
463 rescue FindFailed => e
464 @screen.press('Page_Up')
470 When /^I click on the Tails GitLab URL$/ do
471 @screen.click('PidginTailsGitLabUrl.png')
472 try_for(60) { @torbrowser = Dogtail::Application.new('Firefox') }
475 Then /^Pidgin's D-Bus interface is not available$/ do
476 # Pidgin must be running to expose the interface
477 assert($vm.process_running?('pidgin'))
478 # Let's first ensure it would work if not explicitly blocked.
479 # Note: that the method we pick here doesn't really matter
480 # (`PurpleAccountsGetAll` felt like a convenient choice since it
481 # doesn't require any arguments).
483 Array, pidgin_force_allowed_dbus_call('PurpleAccountsGetAll').class
485 # Finally, let's make sure it is blocked
486 c = pidgin_dbus_call('PurpleAccountsGetAll', return_shellcommand: true)
488 assert_not_nil(c.stderr['Rejected send message'])