Skip to content

Commit 9c8a8e3

Browse files
authored
e2e-tests: Retry invalid TOTP codes during Google login (#1412)
Closes #1398 UDENG-9619
2 parents 1b83099 + 905b5a5 commit 9c8a8e3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+571
-266
lines changed

.github/workflows/e2e-tests-run.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,10 @@ jobs:
258258
cd "${{ steps.run-tests.outputs.output-dir }}"
259259
cp "./log.html" "${PAGES_DIR}/log.html"
260260
cp "./report.html" "${PAGES_DIR}/report.html"
261-
# Upload any recordings (.mp4 files), preserving the directory
261+
262+
# Upload any recordings and images, preserving the directory
262263
# structure so that the links in the log still work
263-
find . -name '*.mp4' -exec cp --parents {} "${PAGES_DIR}/" \;
264+
find . \( -name '*.mp4' -o -name '*.webp' \) -exec cp --parents {} "${PAGES_DIR}/" \;
264265
265266
# Add a link to the parent directory to the log and report pages
266267
for page in "${PAGES_DIR}"/*.html; do

e2e-tests/listener/Listener.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
import sys
23
import time
34
from datetime import timedelta
@@ -35,6 +36,11 @@
3536
# Dumping the whole journal on stderr is too noisy. It's already included
3637
# in the HTML log and stored separately as a .journal file.
3738
"Journal.Log Journal",
39+
# We stream the output of the browser login script continuously to stderr,
40+
# and after the script finishes we log the collected output to the
41+
# Robot Framework log file. To avoid duplication, we mark the login keyword
42+
# as silent so its log messages are not printed to stderr.
43+
"Browser.Login",
3844
]
3945

4046
def _write(text: str) -> None:
@@ -61,6 +67,13 @@ def _status_colour(status: str) -> str:
6167
}.get(status, _WHITE)
6268

6369

70+
def _sanitize_html(text: str) -> str:
71+
"""Remove HTML blocks marked with data-skip-stderr to avoid dumping large HTML content to the terminal."""
72+
# Match any opening tag carrying the data-skip-stderr attribute and strip
73+
# everything from it to the end of the string.
74+
return re.sub(r"<\w[^>]*\bdata-skip-stderr\b[^>]*>.*", "", text, flags=re.DOTALL).rstrip()
75+
76+
6477
def _fmt_args(args: tuple) -> str:
6578
"""Return a compact, single-line representation of keyword arguments."""
6679
if not args:
@@ -120,7 +133,8 @@ def start_test(self, data: running.TestCase, result: result.TestCase) -> None:
120133
def end_test(self, data: running.TestCase, result: result.TestCase) -> None:
121134
elapsed = time.monotonic() - self._test_start
122135
colour = _status_colour(result.status)
123-
msg = f" {_DIM}{result.message}{_RESET}" if result.message else ""
136+
message = _sanitize_html(result.message) if result.message else ""
137+
msg = f" {_DIM}{message}{_RESET}" if message else ""
124138
_write(
125139
f"{colour}{_BOLD}{'PASS' if result.passed else result.status:4}{_RESET}"
126140
f" {_BOLD}{data.name}{_RESET}"
@@ -186,7 +200,6 @@ def log_message(self, message: result.Message) -> None:
186200
# Strip HTML tags if the message is HTML
187201
text = message.message
188202
if message.html:
189-
import re
190203
text = re.sub(r"<[^>]+>", "", text)
191204
_write(f"{indent}{colour}{level_tag}{text}{_RESET}")
192205

e2e-tests/resources/Browser.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import threading
5+
6+
from robot.api.deco import keyword, library # type: ignore
7+
from robot.api import logger
8+
9+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
10+
BROWSER_LOGIN_DIR = os.path.join(SCRIPT_DIR, "browser_login")
11+
12+
# Map broker names to their broker-specific browser login scripts.
13+
_BROKER_LOGIN_SCRIPTS = {
14+
"authd-msentraid": os.path.join(BROWSER_LOGIN_DIR, "msentraid.py"),
15+
"authd-google": os.path.join(BROWSER_LOGIN_DIR, "google.py"),
16+
}
17+
18+
# Write directly to the real stderr, bypassing Robot Framework's capture.
19+
# This matches what Listener.py does so the output ends up in the same stream.
20+
_out = sys.__stderr__
21+
22+
23+
def _stream_to_stderr(stream, lines):
24+
"""Read *stream* line by line, write each line to stderr immediately, and
25+
collect all lines into *lines* for later logging via Robot Framework."""
26+
for line in stream:
27+
stripped = line.rstrip("\n")
28+
if stripped:
29+
_out.write(stripped + "\n")
30+
_out.flush()
31+
lines.append(stripped)
32+
33+
34+
@library
35+
class Browser:
36+
"""Library for browser automation using a headless browser."""
37+
38+
@keyword
39+
def login(self, usercode: str, output_dir: str = "."):
40+
"""Perform device authentication with the given username, password and
41+
usercode using a broker-specific browser automation script. The window
42+
opened by the script is run off screen using Xvfb unless ``SHOW_WEBVIEW``
43+
is set. Output is streamed to stderr in real time and also logged to the
44+
Robot Framework log file after the process completes.
45+
The ``BROKER`` environment variable selects which login script to use.
46+
"""
47+
broker = os.environ.get("BROKER")
48+
script = _BROKER_LOGIN_SCRIPTS.get(broker)
49+
if not script:
50+
raise ValueError(
51+
f"Unknown broker: {broker!r}. "
52+
f"Known brokers: {sorted(_BROKER_LOGIN_SCRIPTS.keys())}"
53+
)
54+
55+
command = [script, usercode, "--output-dir", output_dir]
56+
if not os.getenv("SHOW_WEBVIEW"):
57+
command = ["/usr/bin/env", "GDK_BACKEND=x11", "xvfb-run", "-a", "--"] + command
58+
59+
lines = []
60+
process = subprocess.Popen(
61+
command,
62+
stdout=subprocess.PIPE,
63+
stderr=subprocess.STDOUT,
64+
text=True,
65+
)
66+
stdout_thread = threading.Thread(
67+
target=_stream_to_stderr, args=(process.stdout, lines), daemon=True
68+
)
69+
stdout_thread.start()
70+
process.wait()
71+
stdout_thread.join()
72+
73+
for line in lines:
74+
logger.info(line)
75+
76+
if process.returncode != 0:
77+
raise RuntimeError(
78+
f"Browser login failed (exit code {process.returncode})"
79+
)
Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ async def stop_receiving_journal(self) -> None:
5454
logger.info("Terminating socat")
5555
self.socat_process.terminate()
5656
self.socat_process.wait()
57-
logger.info("socat stderr:\n" + self.socat_process.stderr.read().decode())
57+
socat_stderr = self.socat_process.stderr.read().decode()
58+
socat_stderr_filtered = _filter_socat_stderr(socat_stderr)
59+
logger.info("socat stderr:\n" + socat_stderr_filtered)
5860
self.socat_process = None
5961
# The systemd-journal-remote process should exit on its own when socat terminates
6062
try:
@@ -89,7 +91,67 @@ async def log_journal(self) -> None:
8991
)
9092

9193
html_output = Ansi2HTMLConverter(inline=True).convert(output, full=False)
92-
logger.info(html_output, html=True)
94+
95+
# ansi2html produces a <pre> block; split on newlines so we can wrap each
96+
# line in a <span> that JS can toggle.
97+
lines = html_output.split('\n')
98+
wrapped_lines = ''.join(
99+
f'<span class="jline" style="display:block">{line}</span>'
100+
for line in lines
101+
)
102+
103+
uid = id(html_output) # unique enough within a single report
104+
container_id = f'journal-{uid}'
105+
filter_id = f'journal-filter-{uid}'
106+
count_id = f'journal-count-{uid}'
107+
108+
html = f"""
109+
<input id="{filter_id}"
110+
type="text"
111+
placeholder="Filter journal (plain text or /regex/i)"
112+
style="width:60%;padding:4px 6px;font-size:0.9em;box-sizing:border-box;margin:0"
113+
oninput="(function(){{
114+
var raw = document.getElementById('{filter_id}').value;
115+
var pre = document.getElementById('{container_id}');
116+
var lines = pre.querySelectorAll('.jline');
117+
var re = null;
118+
var m = raw.match(/^\\/(.+)\\/([gimsuy]*)$/);
119+
if (m) {{
120+
try {{ re = new RegExp(m[1], m[2]); }} catch(e) {{ re = null; }}
121+
}}
122+
var shown = 0;
123+
lines.forEach(function(s) {{
124+
var text = s.textContent;
125+
var visible = raw === '' || (re ? re.test(text) : text.toLowerCase().indexOf(raw.toLowerCase()) !== -1);
126+
s.style.display = visible ? 'block' : 'none';
127+
if (visible) shown++;
128+
}});
129+
document.getElementById('{count_id}').textContent =
130+
raw === '' ? '' : shown + ' / ' + lines.length + ' lines';
131+
}})()">
132+
<span id="{count_id}" style="margin-left:8px;font-size:0.85em;color:#888"></span>
133+
<pre id="{container_id}" style="background:#1b1b1b;color:#f8f8f2;overflow:auto;max-height:600px;margin:2px 0 0 0">{wrapped_lines}</pre>
134+
"""
135+
BuiltIn().set_test_message(f'*HTML*<h3 data-skip-stderr style="margin-bottom:0">Journal</h3>{html}', append=True, separator='\n')
136+
137+
def _filter_socat_stderr(stderr):
138+
"""Filter socat stderr, keeping the first write/read line and summarizing the rest."""
139+
lines = []
140+
skipped = 0
141+
first_io_seen = False
142+
for line in stderr.splitlines():
143+
if "write(" in line:
144+
if not first_io_seen:
145+
lines.append(line)
146+
first_io_seen = True
147+
else:
148+
skipped += 1
149+
else:
150+
lines.append(line)
151+
if skipped:
152+
lines.append(f"... ({skipped} more write lines omitted)")
153+
return "\n".join(lines)
154+
93155

94156
def stream_journal_from_vm_via_tcp(output_dir, timeout=60):
95157
vm_name = VMUtils.vm_name()

0 commit comments

Comments
 (0)