Merge branch 'apache-auth'
Build and Publish / test (push) Failing after 1m39s
Build and Publish / build (push) Skipped
Build and Publish / Build and Deploy Docs (push) Skipped

This commit is contained in:
appleb_m
2026-06-10 15:07:13 +02:00
22 changed files with 548 additions and 292 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ dependencies = [
[project.optional-dependencies]
test = [
"pytest==9.0.3",
# "pytest==9.0.3",
"pytest-cov==7.1.0",
"pytest-mock==3.14.0",
"pytest-qt==4.4.0",
+2 -2
View File
@@ -10,8 +10,8 @@ gui:
gonio_camera_id: "1"
daq:
daq_url: "http://mx-x06da-queue-01.psi.ch:5210"
daq_url: "https://mx-x06da-queue-01.psi.ch"
cert_path: "/sls/x06da/misc/.cert/6d.crt"
shared:
jfjoch:
jfjoch_url: "http://sls-gpu-001:8080"
+2 -1
View File
@@ -8,7 +8,8 @@ gui:
gonio_camera_id: ""
daq:
daq_url: "http://mx-x06sa-queue-01.psi.ch:5210"
daq_url: "https://mx-x06sa-queue-01.psi.ch"
cert_path: "/sls/x06sa/misc/.cert/6s.crt"
shared:
jfjoch:
+2 -1
View File
@@ -9,7 +9,8 @@ gui:
gonio_camera_id: "1"
daq:
daq_url: "http://mx-x10sa-queue-01.psi.ch:5210"
daq_url: "https://mx-x10sa-queue-01.psi.ch"
cert_path: "/sls/x10sa/misc/.cert/10s.crt"
shared:
jfjoch:
+83 -5
View File
@@ -1,13 +1,18 @@
import grp
import ipaddress
import logging
import os
import pwd
import re
import uuid
from datetime import datetime, timedelta, UTC
from typing import List
import time
logger = logging.getLogger("aareDAQ")
import jwt
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from pydantic import BaseModel
@@ -30,6 +35,10 @@ BATON_REQUEST_TIMEOUT_SECONDS = 30
STAFF_GROUP = "unx-MXgroup"
SUPER_USERS = ["e10019", "e11206", "e18147"]
APACHE_ACCESS_LOG = "/var/log/httpd/daq-access.log"
# Common Log Format: IP - username [timestamp] "request" status bytes ...
_LOG_PATTERN = re.compile(r'^\S+ \S+ (\S+) \[.*?\] ".*?" (\d+)')
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class TokenData(BaseModel):
@@ -46,15 +55,84 @@ def create_access_token(token: TokenData):
return encoded_jwt
def authenticate_user(cfg: BeamlineConfig, form_data: OAuth2PasswordRequestForm) -> str:
user_info = pwd.getpwnam(form_data.username)
def authenticate_from_apache_log() -> str:
"""
Read the Apache access log and return the username from the most recent
200 response. Apache and the DAQ server are co-located on the same machine.
"""
try:
with open(APACHE_ACCESS_LOG, 'r') as f:
lines = f.readlines()
except OSError as e:
raise AuthenticationException(
message=f"Cannot read Apache access log: {e}",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
) from e
for line in reversed(lines):
m = _LOG_PATTERN.match(line)
if m and m.group(2) == '200' and m.group(1) != '-':
return m.group(1)
raise AuthenticationException(
message="No authenticated user found in Apache access log",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
def _is_loopback(host: str | None) -> bool:
if host is None:
return False
try:
addr = ipaddress.ip_address(host)
# Unwrap IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) before checking
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped.is_loopback
return addr.is_loopback
except ValueError:
return False
def authenticate_from_proxy_header(request: Request) -> str:
"""
Extract the pre-authenticated username from the trusted Apache proxy header.
Only trust this header when the request originates from localhost, meaning
only the Apache proxy running on the same host can supply it.
"""
client_host = request.client.host if request.client else None
logger.debug(f"[auth] /token client_host={client_host!r} request.client={request.client!r}")
if not _is_loopback(client_host):
logger.warning(f"[auth] Rejecting X-Remote-User: client_host {client_host!r} is not loopback")
raise AuthenticationException(
message="X-Remote-User header is only trusted from the localhost proxy",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
remote_user = request.headers.get("X-Remote-User")
if not remote_user:
raise AuthenticationException(
message="Missing X-Remote-User header from proxy",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
return remote_user
def authenticate_user(cfg: BeamlineConfig, username: str) -> str:
user_info = pwd.getpwnam(username)
groups = os.getgrouplist(user_info.pw_name, user_info.pw_gid)
supplementary_groups = [grp.getgrgid(g).gr_name.lower() for g in groups]
super_user = form_data.username in SUPER_USERS
super_user = username in SUPER_USERS
pgroups = [group for group in supplementary_groups if group.startswith('p')]
staff = "unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user
token = TokenData(sub=form_data.username,
token = TokenData(sub=username,
pgroups=pgroups,
staff=staff,
session=cfg.generate_session())
+13 -4
View File
@@ -24,7 +24,7 @@ from aare.common.automation_models import AutomationProgress
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
from aare.common.sample_geometry import SampleGeometryModel
from fastapi import FastAPI, Depends
from fastapi import FastAPI, Depends, Request
from fastapi.concurrency import run_in_threadpool
from fastapi import HTTPException
from fastapi import status as api_status
@@ -256,17 +256,26 @@ async def automation_progress_event_stream() -> AsyncGenerator[str, None]:
return
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
"""
Authenticate a user and return an access token.
When the request carries an X-Remote-User header (set by the Apache Kerberos
proxy), the username is taken from that header and the proxy origin is verified.
Otherwise the username from the form data is used (local / dev access).
Args:
request: The incoming HTTP request (used to inspect headers and client IP).
form_data: OAuth2 password request form containing username and password.
Returns:
A dictionary containing the access token and token type.
"""
data = await run_in_threadpool(auth.authenticate_user, cfg, form_data)
if request.headers.get("X-Remote-User"):
username = auth.authenticate_from_proxy_header(request)
else:
username = auth.authenticate_from_apache_log()
data = await run_in_threadpool(auth.authenticate_user, cfg, username)
return {"access_token": data, "token_type": "bearer"}
@app.get("/meta/error-codes")
@@ -2377,7 +2386,7 @@ def main():
urllib3.disable_warnings()
# Run the application using uvicorn
uvicorn.run("aare.daq.server:app", host="0.0.0.0", port=5210, workers=2, log_config=get_uvicorn_logging_config())
uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=2, proxy_headers=False, log_config=get_uvicorn_logging_config())
if __name__ == "__main__":
+41 -26
View File
@@ -1,6 +1,6 @@
import os
import json
import subprocess
import jwt
import requests
from aare.common.models import TokenData
from aare.common.auth_models import get_user
@@ -8,7 +8,7 @@ from aare.common.logger_config import setup_logger
logger = setup_logger('aareGUI')
def auth(base_url: str | None) -> str:
def auth(base_url: str | None, cert_path: str | None) -> str:
curr_user = get_user()
if base_url is None:
token_data = TokenData(sub=curr_user,
@@ -17,38 +17,53 @@ def auth(base_url: str | None) -> str:
pgroups=["p16371", "p22233"])
return jwt.encode(token_data.model_dump(), "ABC123")
# Single call: Kerberos SPNEGO through Apache, which proxies to the DAQ server.
# Apache injects X-Remote-User; the DAQ server reads it from the localhost-proxied request.
url = f"{base_url}/token"
cacert = f"{cert_path}"
try:
response = requests.post(
url,
data={
"username": curr_user,
"password": ""
},
headers={
"Content-Type": "application/x-www-form-urlencoded"
},
timeout=(3.0, 15.0), # (connect timeout, read timeout)
token_result = subprocess.run(
['curl', '-s', '--cacert', cacert,
'--negotiate', '-u', ':',
'-X', 'POST', url,
'-d', 'username=&password=',
'-H', 'Content-Type: application/x-www-form-urlencoded'],
capture_output=True,
text=True,
timeout=18.0,
)
except requests.RequestException as e:
logger.error(f"Authentication request failed (network): {e}")
except subprocess.TimeoutExpired as e:
logger.error(f"Token request curl timed out: {e}")
raise RuntimeError(
"Cannot reach AareDAQ server (network error). "
"Cannot reach AareDAQ server (timeout). "
"Please check the server is running and your connection."
) from e
if response.status_code != 200:
# Avoid dumping full HTML/tracebacks into the GUI; keep it short and actionable
logger.error(f"Authentication request failed: HTTP {response.status_code}. Body: {response.text[:500]}")
except FileNotFoundError as e:
logger.error(f"curl not found: {e}")
raise RuntimeError("curl not found on this system.") from e
except OSError as e:
logger.error(f"Token request curl OS error: {e}")
raise RuntimeError(
f"Authentication failed (HTTP {response.status_code}). "
"The server may be starting up or unavailable."
"Cannot reach AareDAQ server (OS error). "
"Please check the server is running and your connection."
) from e
except Exception as e:
logger.error(f"Token request curl error: {e}")
raise RuntimeError(
"Cannot reach AareDAQ server (unknown error). "
)
if token_result.returncode != 0:
logger.error(f"Token curl exited {token_result.returncode}. stderr: {token_result.stderr[:500]}")
raise RuntimeError(
f"Token request failed (curl exit {token_result.returncode}). "
"Check Kerberos ticket is valid (kinit) and server is reachable."
)
try:
response_json = response.json()
except ValueError as e:
logger.error(f"Authentication response was not JSON. Body: {response.text[:500]}")
response_json = json.loads(token_result.stdout)
except json.JSONDecodeError as e:
logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}")
raise RuntimeError(
"Authentication failed (invalid server response). "
"The server may be starting up or misconfigured."
@@ -56,7 +71,7 @@ def auth(base_url: str | None) -> str:
token = response_json.get("access_token")
if not token or not isinstance(token, str):
logger.error(f"Authentication response missing access_token. Keys: {list(response_json.keys())}")
logger.error(f"Missing access_token. Keys: {list(response_json.keys())}. Server response {response_json}")
raise RuntimeError(
"Authentication failed (missing token in server response). "
"The server may be starting up."
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+17 -4
View File
@@ -45,21 +45,24 @@ def main():
#TODO if zmq and pred stream come from same source, do not need images from both streams, can combine
match mx_beamline():
case MXBeamline.X06DA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06da-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x06da-pserv-01:9089")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://mx-ml:9091")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "x06da-axis-1.psi.ch")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ed2972e.psi.ch")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 3))
case MXBeamline.X10SA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x10sa-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x10sa-spark-01:9091")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://x10sa-spark-01:9091")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "axis-accc8eb02488.psi.ch")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ea5e463.psi.ch")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case MXBeamline.X06SA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06sa-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
@@ -67,6 +70,7 @@ def main():
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case _:
default_url = cfg_get("gui.daq.daq_url", "")
default_cert_path = cfg_get("gui.daq.cert_path", "")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
@@ -79,6 +83,11 @@ def main():
"url", default_url)
parser.addOption(urlOption)
certPath = QCommandLineOption(["c", "aaredaq-cert-path"],
"Server Certificate Path (for self-signed certificates)",
"cert", default_cert_path)
parser.addOption(certPath)
defaultImage = QCommandLineOption(["i", "image"],
"Default image to display in absence of the ZMQ stream",
"image")
@@ -105,6 +114,10 @@ def main():
if base_url == "" or base_url.lower() == "none":
base_url = None
cert_path = parser.value(certPath)
if cert_path == "" or cert_path.lower() == "none":
cert_path = None
zmq_addr = parser.value(cameraZeroMQ)
if zmq_addr == "":
zmq_addr = None
@@ -123,7 +136,7 @@ def main():
try:
splash.set_progress(50, f"Connecting to {base_url or 'backend'}...")
token = auth(base_url)
token = auth(base_url, cert_path)
if not token or token.count(".") != 2:
raise RuntimeError(
"Authentication did not return a valid token. "
+71 -19
View File
@@ -106,6 +106,13 @@ class MainWindow(QMainWindow):
self._controls_help_dialog = None
self._cleanup_done = False
self._default_window_state = None
self._pre_automation_window_state = None
self._pre_automation_ref_tools_visible = False
self._pre_automation_left_column_visible = True
self._pre_automation_right_column_visible = True
self._in_compact_automation_view = False
self._enter_automation_view_action = None
self._return_main_view_action = None
self._beamline_cam_addr = beamline_cam_addr
self._gonio_cam_addr = gonio_cam_addr
@@ -199,7 +206,7 @@ class MainWindow(QMainWindow):
self.raster = RasterGridManager(geom=geom)
self.rotation = RotationScanManager()
collection_controls_scroll = NoWheelScrollArea(top_widget)
self.collection_controls_scroll = NoWheelScrollArea(top_widget)
self.left_column = QWidget(parent=top_widget)
self.left_column_layout = QVBoxLayout(self.left_column)
@@ -226,13 +233,13 @@ class MainWindow(QMainWindow):
self.beamline_state_panel.hide()
self.left_column_layout.addStretch()
top_widget_layout.addWidget(collection_controls_scroll)
collection_controls_scroll.setWidget(self.left_column)
collection_controls_scroll.setHorizontalScrollBarPolicy(
top_widget_layout.addWidget(self.collection_controls_scroll)
self.collection_controls_scroll.setWidget(self.left_column)
self.collection_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
collection_controls_scroll.setWidgetResizable(True)
collection_controls_scroll.setFixedWidth(
self.collection_controls_scroll.setWidgetResizable(True)
self.collection_controls_scroll.setFixedWidth(
max(
self.data_collection.set_width,
self.loop_centering.sizeHint().width(),
@@ -295,7 +302,7 @@ class MainWindow(QMainWindow):
self.compact_automation_page.setStyleSheet(
"""
QWidget#compactAutomationPage {
background-color: #e8eefc;
background-color: rgb(216, 228, 253);
}
"""
)
@@ -307,15 +314,15 @@ class MainWindow(QMainWindow):
top_widget_layout.addWidget(self.video_tab)
self._start_axis_camera_threads()
beamline_controls_scroll = NoWheelScrollArea(top_widget)
self.beamline_controls_scroll = NoWheelScrollArea(top_widget)
self.beamline = BeamlineControls(beamline_controls_scroll, staff=self.__decoded_token.staff)
top_widget_layout.addWidget(beamline_controls_scroll)
beamline_controls_scroll.setWidget(self.beamline)
beamline_controls_scroll.setHorizontalScrollBarPolicy(
self.beamline = BeamlineControls(self.beamline_controls_scroll, staff=self.__decoded_token.staff)
top_widget_layout.addWidget(self.beamline_controls_scroll)
self.beamline_controls_scroll.setWidget(self.beamline)
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
@@ -478,6 +485,7 @@ class MainWindow(QMainWindow):
self.setWindowTitle("AareGUI")
self.create_menu_bar()
self._update_view_mode_actions()
self._setup_global_shortcuts()
self._capture_default_window_state()
self._restore_window_state()
@@ -916,6 +924,17 @@ class MainWindow(QMainWindow):
key=lambda sample: sample.loc_str_sort(),
)
def _update_view_mode_actions(self) -> None:
in_automation_view = bool(self._in_compact_automation_view)
if self._enter_automation_view_action is not None:
self._enter_automation_view_action.setVisible(not in_automation_view)
self._enter_automation_view_action.setEnabled(not in_automation_view)
if self._return_main_view_action is not None:
self._return_main_view_action.setVisible(in_automation_view)
self._return_main_view_action.setEnabled(in_automation_view)
@Slot()
def enter_compact_automation_view(self) -> None:
self.job_list_panel.ensure_default_queue_from_samples(
@@ -923,6 +942,12 @@ class MainWindow(QMainWindow):
)
self._refresh_compact_queue_preview()
if not self._in_compact_automation_view:
self._pre_automation_window_state = self.saveState()
self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible()
self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible()
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
self.tell_samples_dock.setVisible(False)
self.job_list_dock.setVisible(False)
self.manual_sample_dock.setVisible(False)
@@ -937,12 +962,29 @@ class MainWindow(QMainWindow):
if self.__decoded_token.staff:
self.ref_tools_dock.setVisible(False)
self.collection_controls_scroll.setVisible(False)
self.beamline_controls_scroll.setVisible(False)
self.content_stack.setCurrentWidget(self.compact_automation_page)
self._in_compact_automation_view = True
self._update_view_mode_actions()
@Slot()
def _return_from_compact_automation_view(self) -> None:
self.content_stack.setCurrentWidget(self._standard_main_page)
self.restore_default_view()
if self._pre_automation_window_state is not None:
self.restoreState(self._pre_automation_window_state)
self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible)
self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible)
if self.__decoded_token.staff:
self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.job_list_dock.setVisible(True)
self.tell_samples_dock.setVisible(True)
self.job_list_dock.raise_()
@@ -1025,6 +1067,16 @@ class MainWindow(QMainWindow):
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
self._enter_automation_view_action = QAction("Automation View", self)
self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5"))
self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view)
menu_bar.addAction(self._enter_automation_view_action)
self._return_main_view_action = QAction("Return to Main View", self)
self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5"))
self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view)
menu_bar.addAction(self._return_main_view_action)
view_menu = menu_bar.addMenu("View")
if self._beamline_state_panel_enabled:
@@ -1138,11 +1190,6 @@ class MainWindow(QMainWindow):
beamline_combined_tab_action.triggered.connect(lambda: self.video_tab.setCurrentIndex(3))
view_menu.addAction(beamline_combined_tab_action)
compact_automation_action = QAction("Automation View", self)
compact_automation_action.setShortcut(QKeySequence("Ctrl+5"))
compact_automation_action.triggered.connect(self.enter_compact_automation_view)
view_menu.addAction(compact_automation_action)
view_menu.addSeparator()
restore_default_view_action = QAction("Restore Default View", self)
@@ -1194,6 +1241,11 @@ class MainWindow(QMainWindow):
self.restoreState(self._default_window_state)
self.content_stack.setCurrentWidget(self._standard_main_page)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.collection_controls_scroll.setVisible(True)
self.beamline_controls_scroll.setVisible(True)
self.tell_samples_dock.setVisible(True)
self.job_list_dock.setVisible(True)
+9
View File
@@ -22,6 +22,12 @@ def get_entry(sample: SampleShortInfo, column: int):
elif column == 6:
return sample.mount_count
elif column == 7:
return sample.raster_count
elif column == 8:
return sample.rotation_count
elif column == 9:
return sample.screening_count
elif column == 10:
return sample.comment
@@ -46,6 +52,9 @@ class UserSampleSpreadsheet(QAbstractTableModel):
"Priority",
"User",
"Mount count",
"Raster count",
"Rotation count",
"Screening count",
"Comment",
]
self.current_sample = current_sample
+114 -88
View File
@@ -37,48 +37,52 @@ class CompactAutomationPanel(QFrame):
self.setStyleSheet(
"""
QFrame#compactAutomationPanel {
background: #edf3fb;
background: #d8e4fd;
border: none;
border-radius: 18px;
}
QFrame#compactTopCard,
QFrame#compactCameraCard,
QFrame#compactControlsCard,
QFrame#compactProgressCard,
QFrame#compactControlsCard {
background: #ffffff;
border: 1px solid #d9e4f2;
QFrame#compactQueueCard,
QFrame#compactQueueItem {
background: #e6eefc;
border: 1px solid #b9ccee;
border-radius: 16px;
}
QFrame#compactQueueItem {
background: #f7faff;
border: 1px solid #dbe6f5;
border-radius: 12px;
}
QLabel#compactQueueTitle {
color: #4b5563;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
QLabel#compactQueueValue {
color: #0f172a;
QLabel#compactSectionTitle {
background: transparent;
color: #17324d;
font-size: 14px;
font-weight: 700;
}
QLabel#compactDashboardLabel {
color: #334155;
QLabel#compactSectionHint {
background: transparent;
color: #51657d;
font-size: 12px;
}
QLabel#compactQueueTitle {
background: transparent;
color: #51657d;
font-size: 11px;
font-weight: 700;
}
QLabel#compactQueueValue {
background: transparent;
color: #10263a;
font-size: 14px;
font-weight: 700;
}
QToolButton#compactMenuButton {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
background: #cbdcf8;
color: #17324d;
border: 1px solid #9fb9e5;
border-radius: 14px;
padding: 10px 14px;
font-size: 18px;
@@ -86,23 +90,19 @@ class CompactAutomationPanel(QFrame):
}
QToolButton#compactMenuButton:hover {
background: #dbeafe;
}
QFrame#compactAutomationProgressStrip {
background: transparent;
border: none;
background: #bfd4f6;
}
QLabel#compactProgressSummary {
color: #1e293b;
background: transparent;
color: #17324d;
font-size: 13px;
padding: 2px 2px 6px 2px;
}
QLabel#compactProgressStep {
background: #f8fbff;
border: 1px solid #dbe6f5;
background: #dfe9fb;
border: 1px solid #bfd1ef;
border-radius: 10px;
padding: 8px 6px;
}
@@ -121,10 +121,11 @@ class CompactAutomationPanel(QFrame):
background: #1d4ed8;
}
QPushButton#compactSecondaryButton {
background: #ffffff;
color: #0f172a;
border: 1px solid #cbd5e1;
QPushButton#compactSecondaryButton,
QToolButton#compactSecondaryButton {
background: #dfe9fb;
color: #17324d;
border: 1px solid #b2c7eb;
border-radius: 14px;
padding: 14px 18px;
font-size: 14px;
@@ -133,17 +134,7 @@ class CompactAutomationPanel(QFrame):
QPushButton#compactSecondaryButton:hover,
QToolButton#compactSecondaryButton:hover {
background: #f8fafc;
}
QToolButton#compactSecondaryButton {
background: #ffffff;
color: #0f172a;
border: 1px solid #cbd5e1;
border-radius: 14px;
padding: 14px 18px;
font-size: 14px;
font-weight: 700;
background: #d3e1f8;
}
"""
)
@@ -152,44 +143,18 @@ class CompactAutomationPanel(QFrame):
main_layout.setContentsMargins(18, 18, 18, 18)
main_layout.setSpacing(12)
top_card = QFrame(self)
top_card.setObjectName("compactTopCard")
top_layout = QHBoxLayout(top_card)
top_layout.setContentsMargins(12, 12, 12, 12)
top_layout.setSpacing(10)
camera_card = QFrame(self)
camera_card.setObjectName("compactCameraCard")
camera_layout = QVBoxLayout(camera_card)
camera_layout.setContentsMargins(12, 12, 12, 12)
camera_layout.setSpacing(8)
self.current_card = self._build_queue_item("Current", "")
self.next_card = self._build_queue_item("Next", "")
self.next_next_card = self._build_queue_item("Then", "")
camera_title = QLabel("Camera", self)
camera_title.setObjectName("compactSectionTitle")
camera_layout.addWidget(camera_title)
camera_layout.addWidget(camera_widget, 1)
top_layout.addWidget(self.current_card["frame"], 2)
top_layout.addWidget(self.next_card["frame"], 2)
top_layout.addWidget(self.next_next_card["frame"], 2)
self.menu_button = QToolButton(self)
self.menu_button.setObjectName("compactMenuButton")
self.menu_button.setText("")
self.menu_button.setToolTip("Return to main view")
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
top_layout.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
main_layout.addWidget(top_card)
main_layout.addWidget(camera_widget, 1)
progress_card = QFrame(self)
progress_card.setObjectName("compactProgressCard")
progress_layout = QVBoxLayout(progress_card)
progress_layout.setContentsMargins(12, 10, 12, 10)
progress_layout.setSpacing(6)
progress_title = QLabel("Automation progress", self)
progress_title.setObjectName("compactDashboardLabel")
progress_layout.addWidget(progress_title)
self.progress_strip = CompactAutomationProgressStrip(self)
progress_layout.addWidget(self.progress_strip)
main_layout.addWidget(progress_card)
main_layout.addWidget(camera_card, 1)
controls_card = QFrame(self)
controls_card.setObjectName("compactControlsCard")
@@ -197,9 +162,31 @@ class CompactAutomationPanel(QFrame):
controls_layout.setContentsMargins(12, 12, 12, 12)
controls_layout.setSpacing(10)
controls_header = QHBoxLayout()
controls_header.setSpacing(10)
controls_title_wrap = QVBoxLayout()
controls_title_wrap.setContentsMargins(0, 0, 0, 0)
controls_title_wrap.setSpacing(2)
controls_title = QLabel("Controls", self)
controls_title.setObjectName("compactDashboardLabel")
controls_layout.addWidget(controls_title)
controls_title.setObjectName("compactSectionTitle")
controls_hint = QLabel("Run and manage the automation queue.", self)
controls_hint.setObjectName("compactSectionHint")
controls_title_wrap.addWidget(controls_title)
controls_title_wrap.addWidget(controls_hint)
self.menu_button = QToolButton(self)
self.menu_button.setObjectName("compactMenuButton")
self.menu_button.setText("")
self.menu_button.setToolTip("Return to main view")
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
controls_header.addLayout(controls_title_wrap, 1)
controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
controls_layout.addLayout(controls_header)
button_row = QHBoxLayout()
button_row.setSpacing(10)
@@ -212,7 +199,7 @@ class CompactAutomationPanel(QFrame):
self.skip_button.setObjectName("compactSecondaryButton")
self.skip_button.clicked.connect(self.skip_clicked.emit)
self.step_button = QPushButton("Step Through", self)
self.step_button = QPushButton("Step", self)
self.step_button.setObjectName("compactSecondaryButton")
self.step_button.setCheckable(True)
self.step_button.toggled.connect(self._on_step_toggled)
@@ -238,6 +225,45 @@ class CompactAutomationPanel(QFrame):
controls_layout.addLayout(button_row)
main_layout.addWidget(controls_card)
progress_card = QFrame(self)
progress_card.setObjectName("compactProgressCard")
progress_layout = QVBoxLayout(progress_card)
progress_layout.setContentsMargins(12, 12, 12, 12)
progress_layout.setSpacing(8)
progress_title = QLabel("Progress", self)
progress_title.setObjectName("compactSectionTitle")
progress_layout.addWidget(progress_title)
self.progress_strip = CompactAutomationProgressStrip(self)
progress_layout.addWidget(self.progress_strip)
main_layout.addWidget(progress_card)
queue_card = QFrame(self)
queue_card.setObjectName("compactQueueCard")
queue_layout = QVBoxLayout(queue_card)
queue_layout.setContentsMargins(12, 12, 12, 12)
queue_layout.setSpacing(10)
queue_title = QLabel("Queue Preview", self)
queue_title.setObjectName("compactSectionTitle")
queue_layout.addWidget(queue_title)
queue_row = QHBoxLayout()
queue_row.setSpacing(10)
self.current_card = self._build_queue_item("Now", "")
self.next_card = self._build_queue_item("Next", "")
self.next_next_card = self._build_queue_item("Later", "")
queue_row.addWidget(self.current_card["frame"], 1)
queue_row.addWidget(self.next_card["frame"], 1)
queue_row.addWidget(self.next_next_card["frame"], 1)
queue_layout.addLayout(queue_row)
main_layout.addWidget(queue_card)
def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]:
frame = QFrame(self)
frame.setObjectName("compactQueueItem")
+7 -1
View File
@@ -27,6 +27,12 @@ def get_entry(sample: SampleShortInfo, column: int):
return sample.sample_name
elif column == 2:
return sample.mount_count
elif column == 3:
return sample.rotation_count
elif column == 4:
return sample.raster_count
elif column == 5:
return sample.screening_count
return ""
class ReferenceToolsModel(QAbstractTableModel):
@@ -35,7 +41,7 @@ class ReferenceToolsModel(QAbstractTableModel):
self.samples: list[SampleShortInfo] = rows or []
self.current_reference = current_reference
self.header = ["Position", "Sample name", "Mount count"]
self.header = ["Position", "Sample name", "Mount count", "Raster count", "Rotation count", "Screening count",]
self.__sort_col = 0
self.__sort_order = Qt.SortOrder.AscendingOrder
self.__sorted_samples: list[SampleShortInfo] = []
+14 -1
View File
@@ -10,7 +10,7 @@ from collections import deque
from typing import cast, Literal
from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
from jfjoch_client import ScanResult, ScanResultImagesInner
from aare.common.auth_models import BatonStatus
@@ -120,6 +120,7 @@ class DAQWorker(QObject):
self.__token = token
self.__base_url = base_url
self.__net_manager = QNetworkAccessManager()
self.__net_manager.sslErrors.connect(self._handle_ssl_errors)
self.__timer = QTimer()
self.__timer.setInterval(500)
self.__timer.timeout.connect(self.regular_update)
@@ -326,6 +327,18 @@ class DAQWorker(QObject):
reply = self.__net_manager.get(request)
reply.finished.connect(lambda: self.handle_status_response(reply))
_SELF_SIGNED_ERRORS = {
QSslError.SslError.SelfSignedCertificate,
QSslError.SslError.SelfSignedCertificateInChain,
}
@staticmethod
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
self_signed = [e for e in errors if e.error() in DAQWorker._SELF_SIGNED_ERRORS]
if self_signed:
reply.ignoreSslErrors(self_signed)
@staticmethod
def handle_response(reply: QNetworkReply):
"""
+13 -1
View File
@@ -1,6 +1,6 @@
from typing import Optional, Dict
from PySide6.QtCore import QObject, Signal, Slot, QUrl, QTimer, QByteArray
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
class SSEClient(QObject):
@@ -14,6 +14,7 @@ class SSEClient(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._network_manager = QNetworkAccessManager(self)
self._network_manager.sslErrors.connect(self._handle_ssl_errors)
self._reply: Optional[QNetworkReply] = None
self._reconnect_timer = QTimer(self)
self._reconnect_timer.setSingleShot(True)
@@ -60,6 +61,17 @@ class SSEClient(QObject):
"""Check if connected to SSE"""
return self._connected and self._reply and self._reply.isOpen()
_SELF_SIGNED_ERRORS = {
QSslError.SslError.SelfSignedCertificate,
QSslError.SslError.SelfSignedCertificateInChain,
}
@staticmethod
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
self_signed = [e for e in errors if e.error() in SSEClient._SELF_SIGNED_ERRORS]
if self_signed:
reply.ignoreSslErrors(self_signed)
def _start_connection(self):
"""Start SSE connection"""
request = QNetworkRequest(self._url)
+25 -21
View File
@@ -18,16 +18,24 @@ class CompactAutomationProgressStrip(QFrame):
self._queue_count = 0
self._running = False
self._step_labels: dict[WorkflowStateKind, QLabel] = {}
self._summary_label: QLabel | None = None
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_live_view)
self.setObjectName("compactAutomationProgressStrip")
self.setStyleSheet(
"""
QFrame#compactAutomationProgressStrip {
background: transparent;
border: none;
}
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self._summary_label = QLabel("Automation idle", self)
self._summary_label.setObjectName("compactProgressSummary")
@@ -46,7 +54,6 @@ class CompactAutomationProgressStrip(QFrame):
):
label = QLabel(self)
label.setObjectName("compactProgressStep")
label.setMinimumWidth(90)
label.setWordWrap(True)
steps_row.addWidget(label, 1)
self._step_labels[step] = label
@@ -55,8 +62,7 @@ class CompactAutomationProgressStrip(QFrame):
self._apply_empty_state()
def _apply_empty_state(self) -> None:
if self._summary_label is not None:
self._summary_label.setText("Automation idle")
self._summary_label.setText("Automation idle")
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, StepStatus.PENDING))
@@ -97,9 +103,9 @@ class CompactAutomationProgressStrip(QFrame):
icon = self._step_icon(status)
title = self._step_title(step)
return (
f"<div style='text-align:center;'>"
f"<div style='font-size:18px; color:{color}; font-weight:700;'>{icon}</div>"
f"<div style='font-size:12px; color:{color}; font-weight:700;'>{title}</div>"
f"<div style='text-align:center; background:transparent;'>"
f"<div style='font-size:18px; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:12px; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"</div>"
)
@@ -120,6 +126,7 @@ class CompactAutomationProgressStrip(QFrame):
return "N/A"
return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S")
@Slot()
def _refresh_live_view(self) -> None:
if self._progress is not None:
self.set_progress(self._progress)
@@ -163,19 +170,16 @@ class CompactAutomationProgressStrip(QFrame):
elif self._running:
state_text = "Running"
state_color = "#2563eb"
elif not self._running:
state_text = "Paused"
state_color = "#c2410c"
if self._summary_label is not None:
self._summary_label.setText(
f"<b>Status:</b> <span style='color:{state_color}; font-weight:700;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Current:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done by:</b> {self._format_eta(eta)}"
)
self._summary_label.setText(
f"<span style='background:transparent;'><b>Status:</b> "
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Now:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done:</b> {self._format_eta(eta)}</span>"
)
states = {step.step: step.status for step in progress.steps}
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
@@ -46,4 +46,30 @@ def test_aperture_accepts_float_string():
def test_processingpipeline_accepts_unknown_value_after_refactor():
params = DataCollectionParameters(processingpipeline="xia2")
assert params.processingpipeline == "xia2"
assert params.processingpipeline == "xia2"
def test_datacollectionparameters_accepts_legacy_aliases():
params = DataCollectionParameters(
totalrange=180,
cellparameters="10 20 30 90 90 120",
userresolution=1.4,
)
assert params.totalangle == 180
assert params.unitcell == "10 20 30 90 90 120"
assert params.processingresolution == 1.4
def test_datacollectionparameters_accepts_new_fields():
params = DataCollectionParameters(
totalangle=90,
unitcell="11,22,33,90,90,120",
processingresolution=1.2,
pdbmodel="model.pdb",
cloud=False,
)
assert params.totalangle == 90
assert params.unitcell == "11,22,33,90,90,120"
assert params.processingresolution == 1.2
assert params.pdbmodel == "model.pdb"
assert params.cloud is False
+40 -28
View File
@@ -1,5 +1,14 @@
import pytest
from aare.common.models import SampleShortInfo, DewarAddress, BeamMarkCoeffModel, MLOutputModel, MLBoxType, BeamlineStateEnum
from aare.common.models import (
SampleShortInfo,
DewarAddress,
BeamMarkCoeffModel,
MLOutputModel,
MLBoxType,
BeamlineStateEnum,
DataCollectionParameters,
)
def test_sample_short_info_methods():
info = SampleShortInfo(
@@ -8,34 +17,42 @@ def test_sample_short_info_methods():
dewar_name="dew1",
sample_name="sample1",
run_number=1,
aaredb_params=DataCollectionParameters(
totalangle=180,
processingresolution=1.5,
cloud=True,
),
user="group1",
pin=3,
location=DewarAddress(segment="A", pos=2),
)
# Test tell_address (line 380)
addr = info.tell_address()
assert addr.puck.segment == "A"
assert addr.puck.pos == 2
assert addr.pin == 3
# Test loc_str (lines 383-386)
assert info.loc_str() == "A2-3"
# Test loc_str_sort (lines 389-392)
assert info.loc_str_sort() == "A2-03"
assert info.aaredb_params is not None
assert info.aaredb_params.totalangle == 180
assert info.aaredb_params.processingresolution == 1.5
info_no_loc = info.model_copy(update={"location": None})
assert info_no_loc.loc_str() == "-"
assert info_no_loc.loc_str_sort() == ""
# Test from_dict (line 396)
data = {
"db_id": 2,
"puck_name": "puck2",
"dewar_name": "dew2",
"sample_name": "sample2",
"run_number": 2,
"aaredb_params": {
"totalrange": 120,
"userresolution": 1.8,
"cloud": "",
},
"user": "group2",
"pin": 4,
"location": {"segment": "B", "pos": 5}
@@ -43,47 +60,43 @@ def test_sample_short_info_methods():
info2 = SampleShortInfo.from_dict(data)
assert info2.db_id == 2
assert info2.location.segment == "B"
assert info2.aaredb_params is not None
assert info2.aaredb_params.totalangle == 120
assert info2.aaredb_params.processingresolution == 1.8
assert info2.aaredb_params.cloud is True
def test_beam_mark_coeff_model_apply():
# Test apply (line 414)
model = BeamMarkCoeffModel(
coeff_x=(1.0, 2.0, 5.0),
coeff_y=(3.0, 4.0, 6.0)
)
# zoom = 10
# x = 1.0 * 100 + 2.0 * 10 + 5.0 = 125.0
# y = 3.0 * 100 + 4.0 * 10 + 6.0 = 346.0
res = model.apply(10.0)
assert res.x == 125.0
assert res.y == 346.0
def test_ml_output_model_extra_methods():
model = MLOutputModel()
key = model.add_box(MLBoxType.CRYSTAL, (1, 2, 3, 4), 0.8)
# Test get_box_model (line 499)
box_model = model.get_box_model(key)
assert box_model.conf == 0.8
# Test get_box_tuple (lines 502-505)
assert model.get_box_tuple(key) == (1, 2, 3, 4)
assert model.get_box_tuple("NonExistent") is None
# Test get_box_tuple_with_conf (lines 508-512)
assert model.get_box_tuple_with_conf(key) == (1, 2, 3, 4, 0.8)
assert model.get_box_tuple_with_conf("NonExistent") is None
# Test get_tuples_for_class (lines 523-527)
tuples = model.get_tuples_for_class(MLBoxType.CRYSTAL)
assert len(tuples) == 1
assert tuples[0] == (1, 2, 3, 4)
# Test get_tuples_with_conf_for_class (lines 530-534)
tuples_conf = model.get_tuples_with_conf_for_class(MLBoxType.CRYSTAL)
assert len(tuples_conf) == 1
assert tuples_conf[0] == (1, 2, 3, 4, 0.8)
# Test get_class_str (lines 470, 475-481)
assert MLOutputModel.get_class_str(MLBoxType.LOOP_ALL) == "Loop_all"
assert MLOutputModel.get_class_str(MLBoxType.PIN) == "Pin"
assert MLOutputModel.get_class_str(MLBoxType.CRYSTAL) == "Crystal"
@@ -92,9 +105,8 @@ def test_ml_output_model_extra_methods():
assert MLOutputModel.get_class_str(MLBoxType.NEEDLE) == "Needle"
assert MLOutputModel.get_class_str(100) == "Unknown"
def test_beamline_state_enum_display_name():
# Test display_name (line 556)
assert BeamlineStateEnum.SampleExchange.display_name() == "Sample exchange"
assert BeamlineStateEnum.Moving.display_name() == "Moving"
# Using value for potentially unknown
assert BeamlineStateEnum.display_name(None) == "-"
assert BeamlineStateEnum.display_name(None) == "-"
+10 -11
View File
@@ -50,25 +50,24 @@ def test_authenticate_user(mock_cfg):
patch('os.getgrouplist') as mock_groups, \
patch('grp.getgrgid') as mock_grp, \
patch('aare.daq.auth.SECRET_KEY', 'test_secret'):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gid = 1000
mock_groups.return_value = [1000, 1001]
def get_group(gid):
m = MagicMock()
if gid == 1000: m.gr_name = "p12345"
else: m.gr_name = "unx-MXgroup"
if gid == 1000:
m.gr_name = "p12345"
else:
m.gr_name = "unx-MXgroup"
return m
mock_grp.side_effect = get_group
form_data = MagicMock()
form_data.username = "testuser"
token = authenticate_user(mock_cfg, form_data)
token = authenticate_user(mock_cfg, "testuser")
assert isinstance(token, str)
payload = jwt.decode(token, 'test_secret', algorithms=["HS256"])
assert payload["sub"] == "testuser"
assert "p12345" in payload["pgroups"]
+7 -3
View File
@@ -49,9 +49,13 @@ def test_omega_put(client):
def test_login_success(client):
with patch("aare.daq.auth.authenticate_user") as mock_auth:
mock_auth.return_value = "fake-access-token"
response = client.post("/token", data={"username": "user", "password": "pwd"})
with patch("aare.daq.auth.authenticate_from_proxy_header", return_value="user"), \
patch("aare.daq.auth.authenticate_user", return_value="fake-access-token"):
response = client.post(
"/token",
data={"username": "user", "password": "pwd"},
headers={"X-Remote-User": "user"},
)
assert response.status_code == 200
assert response.json() == {"access_token": "fake-access-token", "token_type": "bearer"}
+22 -18
View File
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
from aare.daq.spreadsheetupdater import on_message, get_ws_headers, set_spreadsheet_in_redis
from aare.common.models import SampleShortInfoList
@pytest.fixture
def mock_config():
with patch('aare.daq.spreadsheetupdater.config') as mock:
@@ -14,30 +15,28 @@ def mock_config():
mock._BeamlineConfig__bl = mock._BeamlineConfig__bl
yield mock
def test_get_ws_headers_success():
with patch('os.getenv', return_value="secret"):
headers = get_ws_headers()
assert headers == ["X-Shared-Password: secret"]
def test_get_ws_headers_fail():
with patch('os.getenv', return_value=None):
with pytest.raises(ValueError):
get_ws_headers()
def test_set_spreadsheet_in_redis(mock_config):
data = {"test": "data"}
with patch('aare.daq.spreadsheetupdater.config') as mock_cfg_internal:
# Mocking BOTH possible name-mangled names for the client
mock_client = MagicMock()
mock_cfg_internal._BeamlineConfig__client = mock_client
mock_cfg_internal.client = mock_client # In case it's not mangled or mangled differently
# Also need to mock where it's actually used: config.__client.set
# If I can't guess the mangling, I'll just check what attributes mock_cfg_internal has
mock_cfg_internal.client = mock_client
set_spreadsheet_in_redis(data)
# Check all mock attributes for a 'set' call
found = False
for attr in dir(mock_cfg_internal):
val = getattr(mock_cfg_internal, attr)
@@ -46,6 +45,7 @@ def test_set_spreadsheet_in_redis(mock_config):
break
assert found or mock_client.set.called
def test_on_message_success(mock_config):
message = json.dumps({
"samples": [
@@ -67,6 +67,9 @@ def test_on_message_success(mock_config):
"position": 1,
"priority": 1,
"mount_count": 0,
"rotation_count": 0,
"raster_count": 0,
"screening_count": 0,
"data_collection_parameters": {}
}
]
@@ -89,24 +92,26 @@ def test_on_message_success(mock_config):
"position": 1,
"priority": 1,
"mount_count": 0,
"rotation_count": 0,
"raster_count": 0,
"screening_count": 0,
"data_collection_parameters": {}
}
]
}
]
})
on_message(None, message)
# Check if normal spreadsheet was written
normal_key = "X10SA:sample_spreadsheet"
calls = mock_config._BeamlineConfig__client.set.call_args_list
assert any(call.args[0] == normal_key for call in calls)
# Check if reference tools were written
ref_key = "X10SA:reference-tools"
assert any(call.args[0] == ref_key for call in calls)
def test_on_message_empty_ref(mock_config):
message = json.dumps({
"samples": [
@@ -123,14 +128,13 @@ def test_on_message_empty_ref(mock_config):
}
]
})
on_message(None, message)
# Check if reference tools were deleted
ref_key = "X10SA:reference-tools"
mock_config._BeamlineConfig__client.delete.assert_called_with(ref_key)
def test_on_message_invalid_json(mock_config):
# Should not raise exception, just print error
on_message(None, "invalid json")
mock_config._BeamlineConfig__client.set.assert_not_called()
mock_config._BeamlineConfig__client.set.assert_not_called()
+28 -56
View File
@@ -1,72 +1,44 @@
import json
import subprocess
import pytest
import requests
from aare.gui.auth import auth
# pytest-mock provides the 'mocker' fixture, which is a wrapper around the
# standard unittest.mock. It simplifies mocking by automatically handling
# cleanup (unpatching) after each test, and providing a more "pytest-native"
# feel compared to using @patch decorators or context managers.
def test_auth_success(mocker):
"""
Test successful authentication using pytest-mock's mocker fixture.
In standard pytest/unittest, you would typically use:
with mock.patch('requests.post') as mock_post:
...
Or a decorator:
@patch('requests.post')
def test_auth(mock_post):
...
pytest-mock allows you to use the 'mocker' fixture directly in the function arguments.
This avoids deeply nested context managers and makes it easier to mock multiple things.
"""
# We mock 'requests.post' to simulate a successful server response.
# mocker.patch returns a MagicMock object.
mock_post = mocker.patch("requests.post")
# Configure the mock response
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"access_token": "fake_token_abc.123.xyz"}
mock_post.return_value = mock_response
# Call the function under test
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
mock_run.return_value = mocker.Mock(
returncode=0,
stdout=json.dumps({"access_token": "fake_token_abc.123.xyz"}),
stderr="",
)
token = auth("http://test-server")
# Verify the results
assert token == "fake_token_abc.123.xyz"
mock_post.assert_called_once()
# Check that it was called with the expected URL
args, kwargs = mock_post.call_args
assert args[0] == "http://test-server/token"
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
assert "curl" in args[0]
assert "http://test-server/token" in args[0]
def test_auth_network_failure(mocker):
"""
Test authentication failure due to network error using mocker.
"""
# Mock requests.post to raise an exception
mock_post = mocker.patch("requests.post")
mock_post.side_effect = requests.RequestException("Connection refused")
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
mock_run.side_effect = OSError("Connection refused")
with pytest.raises(RuntimeError) as excinfo:
auth("http://test-server")
assert "Cannot reach AareDAQ server" in str(excinfo.value)
def test_auth_no_url_returns_dummy_jwt(mocker):
"""
Test that when base_url is None, it returns a dummy JWT without network calls.
We can use mocker to verify that requests.post was NEVER called.
"""
mock_post = mocker.patch("requests.post")
mocker.patch("os.getlogin", return_value="testuser")
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
mocker.patch("aare.gui.auth.get_user", return_value="testuser")
token = auth(None)
assert isinstance(token, str)
assert token.count('.') == 2 # Basic JWT structure check
mock_post.assert_not_called()
assert token.count('.') == 2
mock_run.assert_not_called()