Skip to content

Commit 79f6a92

Browse files
devzbysiuSavolro
authored andcommitted
LVPN-8307: Update tests for consent (#932)
1 parent cbbc15b commit 79f6a92

File tree

15 files changed

+208
-18
lines changed

15 files changed

+208
-18
lines changed

.github/workflows/ci-gitlab.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ jobs:
1818
project-id: ${{ secrets.PROJECT_ID }}
1919
with:
2020
cancel-outdated-pipelines: ${{ github.ref_name != 'main' }}
21-
triggered-ref: v1.1.0
21+
triggered-ref: v1.1.1
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
selenium==4.19.0
2-
pytest-html==4.1.1
2+
pytest-html==4.1.1
3+
pexpect==4.8.0

ci/docker/tester/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ protobuf==5.28.3
1111
selenium==4.26.0
1212
grpcio==1.68.1
1313
pytest-html==4.1.1
14+
pexpect==4.8.0

cli/messages.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,8 @@ Provide a [transfer_id] argument to list files in the specified transfer.`
371371
SetPqAndMeshnetServer = "Meshnet isn’t compatible with post-quantum encryption. Reconnect to the VPN to fully disable post-quantum protection and try again."
372372
SetPqUsageText = "Enables or disables post-quantum encryption. When enabled, the encryption protects your VPN connection against potential quantum computer attacks.\nNote: Currently, post-quantum encryption works only with standard NordLynx servers, so it won’t activate when you use a dedicated IP, OpenVPN, NordWhisper or obfuscated servers.\nThe feature is not compatible with Meshnet."
373373

374+
SetDefaultsLogoutFlagText = " Log out after restoring settings to their default values. Example: nordvpn set defaults --logout"
375+
374376
AnalyticsPolicyLink = "https://my.nordaccount.com/legal/privacy-policy/?utm_medium=app&utm_source=nordvpn-linux-cli&utm_campaign=settings_account-privacy_policy&nm=app&ns=nordvpn-linux-cli&nc=settings-privacy_policy"
375377
MsgConsentAgreement = `We value your privacy.
376378

daemon/userconsent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func (acc *AnalyticsConsentChecker) consentModeFromUserLocation() consentMode {
160160
}
161161

162162
mode := modeForCountryCode(core.NewCountryCode(cc))
163-
log.Printf(internal.DebugPrefix+" consent mode for country code '%s': %s", insights.CountryCode, mode)
163+
log.Printf(internal.DebugPrefix+" consent mode for country code '%s': %s\n", cc, mode)
164164
return mode
165165
}
166166

magefiles/mage.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const (
2323
imageSnapPackager = registryPrefix + "snaper:1.0.0"
2424
imageProtobufGenerator = registryPrefix + "generator:1.4.1"
2525
imageScanner = registryPrefix + "scanner:1.1.0"
26-
imageTester = registryPrefix + "tester:1.4.0"
26+
imageTester = registryPrefix + "tester:1.5.0"
2727
imageQAPeer = registryPrefix + "qa-peer:1.0.4"
2828
imageRuster = registryPrefix + "ruster:1.3.1"
2929

test/qa/lib/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@
144144
]
145145

146146

147+
EXPECTED_CONSENT_MESSAGE = """
148+
We value your privacy.
149+
150+
That's why we want to be transparent about what data you agree to give us. We only collect the bare minimum of information required to offer a smooth and stable VPN experience.
151+
152+
By pressing "y" (yes), you allow us to collect and use limited app performance data. This helps us keep our features relevant to your needs and fix issues faster, as explained in our Privacy Policy.
153+
https://my.nordaccount.com/legal/privacy-policy/?utm_medium=app&utm_source=nordvpn-linux-cli&utm_campaign=settings_account-privacy_policy&nm=app&ns=nordvpn-linux-cli&nc=settings-privacy_policy
154+
155+
Press "n" (no) to send only the essential data our app needs to work.
156+
157+
Your browsing activities remain private, regardless of your choice.
158+
"""
159+
WE_VALUE_YOUR_PRIVACY_MSG = "We value your privacy"
160+
USER_CONSENT_PROMPT = "Do you allow us to collect and use limited app performance data\? \(y/n\)"
161+
162+
163+
class UserConsentMode(str, Enum):
164+
ENABLED = "enabled"
165+
DISABLED = "disabled"
166+
UNDEFINED = "undefined"
167+
168+
147169
class Protocol(Enum):
148170
UDP = "UDP"
149171
TCP = "TCP"
@@ -385,3 +407,8 @@ def technology_to_upper_camel_case(tech: str) -> str:
385407
return "OpenVPN"
386408
case "NORDWHISPER":
387409
return "NordWhisper"
410+
411+
412+
def squash_whitespace(text: str) -> str:
413+
"""Normalize whitespace by collapsing all sequences of whitespace into single spaces."""
414+
return ' '.join(text.split())

test/qa/lib/login.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
22
import os
3+
import io
4+
import pexpect
35

46
import sh
57

6-
from . import logging, ssh
8+
from . import UserConsentMode, logging, ssh, squash_whitespace, WE_VALUE_YOUR_PRIVACY_MSG, USER_CONSENT_PROMPT
79

810

911
class Credentials:
@@ -33,12 +35,59 @@ def get_credentials(key) -> Credentials:
3335
password=creds.get("password", None))
3436

3537

36-
def login_as(username, ssh_client: ssh.Ssh = None):
37-
"""login_as specified user with optional delay before calling login."""
38+
def login_as(username, ssh_client: ssh.Ssh = None, with_user_consent: UserConsentMode = UserConsentMode.ENABLED):
39+
"""login_as specified user, optional SSH connection and option for setting user consent before calling login."""
3840
token = get_credentials(username).token
3941

4042
logging.log(f"logging in as {token}")
4143

4244
if ssh_client is not None:
45+
if with_user_consent != UserConsentMode.UNDEFINED:
46+
ssh_client.exec_command(f"nordvpn set analytics {_analytics_value(with_user_consent)}")
4347
return ssh_client.exec_command(f"nordvpn login --token {token}")
48+
49+
if with_user_consent != UserConsentMode.UNDEFINED:
50+
sh.nordvpn.set.analytics(_analytics_value(with_user_consent))
4451
return sh.nordvpn.login("--token", token)
52+
53+
54+
def _analytics_value(mode: UserConsentMode) -> str:
55+
if mode == UserConsentMode.UNDEFINED:
56+
raise Exception("can't set analytics with undefined consent")
57+
58+
if mode == UserConsentMode.ENABLED:
59+
return "on"
60+
61+
if mode == UserConsentMode.DISABLED:
62+
return "off"
63+
64+
msg = f"not supported consent mode: {mode}"
65+
raise Exception(msg)
66+
67+
68+
def spawn_nordvpn_login():
69+
"""Spawns the nordvpn login process and sets up an output buffer."""
70+
buffer = io.StringIO()
71+
cli = pexpect.spawn("nordvpn", args=["login"], encoding="utf-8", timeout=10)
72+
cli.logfile_read = buffer
73+
return cli, buffer
74+
75+
76+
def wait_for_consent_prompt(cli):
77+
"""Waits for the consent prompt to appear."""
78+
cli.expect(USER_CONSENT_PROMPT)
79+
80+
81+
def get_new_output(buffer, old_output=""):
82+
"""Returns only the newly added output since old_output."""
83+
return buffer.getvalue()[len(old_output):]
84+
85+
86+
def assert_prompt_present(output, message=WE_VALUE_YOUR_PRIVACY_MSG):
87+
"""Asserts that the consent message is in the output."""
88+
assert message in squash_whitespace(output)
89+
90+
91+
def assert_prompt_absent(output, message=WE_VALUE_YOUR_PRIVACY_MSG):
92+
"""Asserts that the consent message is not in the output."""
93+
assert message not in squash_whitespace(output)

test/qa/lib/settings.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import sh
44

5+
from . import UserConsentMode
6+
57

68
MSG_AUTOCONNECT_ENABLE_SUCCESS = "Auto-connect is set to 'enabled' successfully."
79
MSG_AUTOCONNECT_DISABLE_SUCCESS = "Auto-connect is set to 'disabled' successfully."
@@ -109,9 +111,25 @@ def is_dns_disabled():
109111
return Settings().get("DNS") == "disabled"
110112

111113

112-
def are_analytics_enabled():
113-
"""Returns True, if Analytics are enabled in application settings."""
114-
return Settings().get("Analytics") == "enabled"
114+
def is_user_consent_granted():
115+
"""
116+
Returns True, if User Consent is enabled, False if it's disabled.
117+
118+
If the consent was not declared. It raises an exception.
119+
"""
120+
user_consent = Settings().get("user consent")
121+
if user_consent == UserConsentMode.ENABLED:
122+
return True
123+
124+
if user_consent == UserConsentMode.DISABLED:
125+
return False
126+
127+
raise Exception("user consent is undefined")
128+
129+
130+
def is_user_consent_declared():
131+
"""Returns True, if User Consent is enabled or disabled, False if it is undefined in application settings."""
132+
return Settings().get("user consent") != UserConsentMode.UNDEFINED
115133

116134

117135
def is_virtual_location_enabled():
@@ -132,7 +150,8 @@ def app_has_defaults_settings():
132150
"Firewall: enabled" in settings and
133151
"Firewall Mark: 0xe1f1" in settings and
134152
"Routing: enabled" in settings and
135-
"Analytics: enabled" in settings and
153+
# User Consent is not restored to default on reset
154+
("User Consent: enabled" in settings or "User Consent: disabled" in settings) and
136155
"Kill Switch: disabled" in settings and
137156
"Threat Protection Lite: disabled" in settings and
138157
"Notify: enabled" in settings and

test/qa/test_login.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
import pexpect
23
import sh
34

45
import lib
@@ -8,6 +9,7 @@
89
logging,
910
login,
1011
network,
12+
settings,
1113
)
1214

1315

@@ -28,6 +30,65 @@ def teardown_function(function): # noqa: ARG001
2830
logging.log()
2931

3032

33+
def test_user_consent_is_displayed_on_login():
34+
cli, _ = login.spawn_nordvpn_login()
35+
login.wait_for_consent_prompt(cli)
36+
37+
full_output = cli.before + cli.after # everything printed so far, including the last line
38+
39+
assert (
40+
lib.squash_whitespace(lib.EXPECTED_CONSENT_MESSAGE)
41+
in lib.squash_whitespace(full_output)
42+
), "Consent message did not match expected full output"
43+
44+
45+
def test_invalid_input_repeats_consent_prompt_only():
46+
cli, buffer = login.spawn_nordvpn_login()
47+
login.wait_for_consent_prompt(cli)
48+
first_output = buffer.getvalue()
49+
50+
cli.sendline("blah")
51+
login.wait_for_consent_prompt(cli)
52+
second_output = login.get_new_output(buffer, first_output)
53+
54+
login.assert_prompt_present(first_output)
55+
login.assert_prompt_absent(second_output)
56+
assert "Invalid response" in lib.squash_whitespace(second_output)
57+
assert "(y/n)" in lib.squash_whitespace(second_output)
58+
59+
60+
def test_user_consent_prompt_reappears_after_ctrl_c_interrupt():
61+
cli1, buffer1 = login.spawn_nordvpn_login()
62+
login.wait_for_consent_prompt(cli1)
63+
cli1.sendintr()
64+
cli1.expect(pexpect.EOF)
65+
output1 = buffer1.getvalue()
66+
login.assert_prompt_present(output1)
67+
68+
cli2, buffer2 = login.spawn_nordvpn_login()
69+
login.wait_for_consent_prompt(cli2)
70+
output2 = buffer2.getvalue()
71+
login.assert_prompt_present(output2)
72+
73+
74+
def test_user_consent_granted_after_pressing_y_and_does_not_appear_again():
75+
cli, _ = login.spawn_nordvpn_login()
76+
login.wait_for_consent_prompt(cli)
77+
78+
assert not settings.is_user_consent_declared(), "Consent should not be declared before interaction"
79+
cli.sendline("y")
80+
cli.expect(pexpect.EOF)
81+
assert settings.is_user_consent_granted(), "Consent should be recorded after pressing 'y'"
82+
83+
cli2, _ = login.spawn_nordvpn_login()
84+
try:
85+
cli2.expect(lib.USER_CONSENT_PROMPT)
86+
raise AssertionError("Consent prompt appeared again after it was already granted")
87+
except (pexpect.exceptions.TIMEOUT, pexpect.exceptions.EOF):
88+
pass # Good, prompt did not appear
89+
cli2.expect(pexpect.EOF)
90+
91+
3192
def test_login():
3293
with lib.Defer(lambda: sh.nordvpn.logout("--persist-token")):
3394
output = login.login_as("default")

test/qa/test_login_nordaccount.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def test_selenium_login_callback(login_flag):
7373
browser = sel.browser_get()
7474

7575
with lib.Defer(sel.browser_kill):
76+
sh.nordvpn.set.analytics("on")
7677
# Get login link from NordVPN app, trim all spaces & chars after link itself
7778
login_link = sh.nordvpn.login(login_flag, _tty_out=False).strip().split(": ")[1]
7879
print(f"Login link: {login_link}\n")

test/qa/test_meshnet_other.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_set_defaults_when_logged_out_1st_set(tech, proto, obfuscated):
8282
assert "0xe1f1" not in sh_no_tty.nordvpn.settings()
8383
assert daemon.is_killswitch_on()
8484
assert settings.is_lan_discovery_enabled()
85-
assert not settings.are_analytics_enabled()
85+
assert settings.is_user_consent_declared()
8686
assert settings.is_tpl_enabled()
8787

8888
if obfuscated == "on":

test/qa/test_settings.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import sh
3+
import pexpect
34

45
import lib
56
from lib import daemon, dns, info, logging, login, network, settings
@@ -81,7 +82,7 @@ def test_set_defaults_when_logged_in_1st_set(tech, proto, obfuscated):
8182
assert not settings.is_firewall_enabled()
8283
assert not settings.is_routing_enabled()
8384
assert not settings.is_dns_disabled()
84-
assert not settings.are_analytics_enabled()
85+
assert settings.is_user_consent_declared()
8586
assert settings.is_notify_enabled()
8687
assert not settings.is_virtual_location_enabled()
8788

@@ -154,7 +155,7 @@ def test_set_defaults_when_connected_1st_set(tech, proto, obfuscated):
154155

155156
assert not settings.is_routing_enabled()
156157
assert not settings.is_dns_disabled()
157-
assert not settings.are_analytics_enabled()
158+
assert settings.is_user_consent_declared()
158159
assert settings.is_lan_discovery_enabled()
159160
assert not settings.is_virtual_location_enabled()
160161

@@ -225,6 +226,34 @@ def test_is_custom_dns_removed_after_setting_defaults(tech, proto, obfuscated, n
225226
assert not dns.is_set_for(nameserver)
226227

227228

229+
def test_set_analytics_starts_prompt_even_if_completed_before():
230+
# first run: see prompt and respond
231+
cli1 = pexpect.spawn("nordvpn", args=["set", "analytics"], encoding='utf-8', timeout=10)
232+
cli1.expect(lib.USER_CONSENT_PROMPT)
233+
output1 = cli1.before + cli1.after
234+
235+
assert (
236+
lib.squash_whitespace(lib.EXPECTED_CONSENT_MESSAGE)
237+
in lib.squash_whitespace(output1)
238+
), "Consent message did not match expected full output on first run"
239+
240+
cli1.sendline("n")
241+
cli1.expect(pexpect.EOF)
242+
243+
# second run: should see the prompt again
244+
cli2 = pexpect.spawn("nordvpn", args=["set", "analytics"], encoding='utf-8', timeout=10)
245+
cli2.expect(lib.USER_CONSENT_PROMPT)
246+
output2 = cli2.before + cli2.after
247+
248+
assert (
249+
lib.squash_whitespace(lib.EXPECTED_CONSENT_MESSAGE)
250+
in lib.squash_whitespace(output2)
251+
), "Consent message did not appear again on second run"
252+
253+
cli2.sendline("y")
254+
cli2.expect(pexpect.EOF)
255+
256+
228257
def test_set_defaults_no_logout():
229258
sh.nordvpn.set.defaults()
230259

@@ -234,10 +263,10 @@ def test_set_defaults_no_logout():
234263
def test_set_analytics_off_on():
235264

236265
assert "Analytics is set to 'disabled' successfully." in sh.nordvpn.set.analytics("off")
237-
assert not settings.are_analytics_enabled()
266+
assert not settings.is_user_consent_granted()
238267

239268
assert "Analytics is set to 'enabled' successfully." in sh.nordvpn.set.analytics("on")
240-
assert settings.are_analytics_enabled()
269+
assert settings.is_user_consent_granted()
241270

242271

243272
def test_set_analytics_on_off_repeated():

third-party/moose-events

Submodule moose-events updated from 8f4282c to 72c5cb1

third-party/moose-worker

Submodule moose-worker updated from f920b06 to dc5bef3

0 commit comments

Comments
 (0)