STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_directory.py
Go to the documentation of this file.
1"""The share picker's roster (backend/directory.py).
2
3Sharing needs a list of people, and this app has never had one. The roster is
4the union of the auth service's login records and whoever already has data on
5the shared volume -- it needs both, because auth only learns about someone when
6they log in (30-day sessions, so the roster starts nearly empty) and dev runs
7with no auth service at all.
8
9The invariant that matters: auth being unset, down, slow, or lying must never
10break listing or sharing designs.
11"""
12
13import json
14import os
15import sys
16import urllib.error
17
18import pytest
19
20sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
22pytest.importorskip("fastapi", reason="directory imports fastapi.Request")
23
24from stardesign import directory as shared_directory # noqa: E402
25
26from backend import directory, userdata # noqa: E402
27
28
29class _Req:
30 def __init__(self, cookie=""):
31 self.headers = {"cookie": cookie} if cookie else {}
32
33
34@pytest.fixture(autouse=True)
35def _isolate(tmp_path, monkeypatch):
36 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
37 monkeypatch.delenv("AUTH_USERS_URL", raising=False)
38 # The roster cache is process-wide; a stale entry would leak between tests.
39 monkeypatch.setattr(shared_directory, "_cache", None)
40
41
42def _fake_auth(monkeypatch, rows, *, calls=None):
43 def fake(req, timeout=None):
44 if calls is not None:
45 calls.append(req)
46
47 class _Resp:
48 def read(self):
49 return json.dumps(rows).encode()
50
51 def __enter__(self):
52 return self
53
54 def __exit__(self, *a):
55 return False
56
57 return _Resp()
58
59 monkeypatch.setenv("AUTH_USERS_URL", "https://auth.example.org")
60 monkeypatch.setattr(shared_directory.urllib.request, "urlopen", fake)
61
62
64 """Dev, and the first weeks in prod: no auth roster, but the picker still
65 has to be usable."""
66 userdata.user_dir("alice@berkeley.edu")
67 userdata.user_dir("bob@berkeley.edu")
68 assert directory.roster(_Req()) == [
69 {"email": "alice@berkeley.edu", "name": ""},
70 {"email": "bob@berkeley.edu", "name": ""},
71 ]
72
73
75 """The volume only ever holds the slug; auth knows the real address and the
76 display name, and the same person must not appear twice."""
77 userdata.user_dir("alice@berkeley.edu")
78 _fake_auth(monkeypatch, [
79 {"email": "Alice@Berkeley.edu", "name": "Alice Adams"},
80 {"email": "carol@berkeley.edu", "name": "Carol Chen"},
81 ])
82 assert directory.roster(_Req()) == [
83 {"email": "Alice@Berkeley.edu", "name": "Alice Adams"},
84 {"email": "carol@berkeley.edu", "name": "Carol Chen"},
85 ]
86
87
89 _fake_auth(monkeypatch, [{"email": "dana@berkeley.edu", "name": "Dana"}])
90 assert [u["email"] for u in directory.roster(_Req())] == ["dana@berkeley.edu"]
91
92
94 """This app becomes an authenticated-request forwarder. It must present the
95 caller's own cookie and nothing else, to a URL it took from the environment
96 rather than the request."""
97 calls = []
98 _fake_auth(monkeypatch, [], calls=calls)
99 directory.roster(_Req(cookie="session=abc123"))
100 (req,) = calls
101 assert req.full_url == "https://auth.example.org/users"
102 assert req.get_header("Cookie") == "session=abc123"
103 # The static User-Agent below is required because Cloudflare blocks urllib's
104 # default signature; it carries nothing from the request, so the "the caller's
105 # own cookie and nothing else" property this test guards still holds.
106 assert set(req.headers) == {"Cookie", "User-agent"}
107
108
109@pytest.mark.parametrize("boom", [
110 urllib.error.URLError("down"),
111 OSError("refused"),
112 TimeoutError("slow"),
113 ValueError("not json"),
114])
116 userdata.user_dir("alice@berkeley.edu")
117 monkeypatch.setenv("AUTH_USERS_URL", "https://auth.example.org")
118
119 def explode(req, timeout=None):
120 raise boom
121
122 monkeypatch.setattr(shared_directory.urllib.request, "urlopen", explode)
123 assert [u["email"] for u in directory.roster(_Req())] == ["alice@berkeley.edu"]
124
125
126@pytest.mark.parametrize("garbage", [{"not": "a list"}, ["a string"], [{"no": "email"}]])
128 userdata.user_dir("alice@berkeley.edu")
129 _fake_auth(monkeypatch, garbage)
130 assert [u["email"] for u in directory.roster(_Req())] == ["alice@berkeley.edu"]
131
132
133def test_roster_is_cached(monkeypatch):
134 calls = []
135 _fake_auth(monkeypatch, [{"email": "dana@berkeley.edu", "name": "Dana"}], calls=calls)
136 directory.roster(_Req())
137 directory.roster(_Req())
138 assert len(calls) == 1, "listing designs must not be a network call per request"
139
140
142 _fake_auth(monkeypatch, [{"email": "Alice@Berkeley.edu", "name": "Alice Adams"}])
143 assert directory.display_names(_Req())["alice@berkeley.edu"] == "Alice Adams"
144
145
147 """The cache must not mistake "never fetched" for "fetched just now".
148
149 `time.monotonic()` counts from boot, so a sentinel timestamp of 0.0 reads as
150 stale on a machine that has been up for hours and as *fresh* on one that has
151 not. That shipped: for the first TTL after every restart the roster came back
152 empty, from a cache that had never been filled, without auth being asked.
153 Local runs never caught it -- a fresh CI container did.
154 """
155 calls = []
156 _fake_auth(monkeypatch, [{"email": "dana@berkeley.edu", "name": "Dana"}], calls=calls)
157 monkeypatch.setattr(shared_directory, "_cache", None) # a process that just started
158 monkeypatch.setattr(shared_directory.time, "monotonic", lambda: 3.0) # ...on a fresh host
159
160 assert [u["email"] for u in directory.roster(_Req())] == ["dana@berkeley.edu"]
161 assert len(calls) == 1, "a cold cache must hit auth, whatever the clock reads"
__init__(self, cookie="")
test_auth_users_who_never_opened_this_app_are_still_shareable(monkeypatch)
test_malformed_auth_response_is_ignored(monkeypatch, garbage)
test_forwards_only_the_session_cookie(monkeypatch)
test_volume_users_alone_when_auth_is_unset()
test_a_fresh_process_asks_auth_even_on_a_just_booted_machine(monkeypatch)
test_display_names_maps_slugs(monkeypatch)
_isolate(tmp_path, monkeypatch)
test_union_prefers_auths_spelling_and_name(monkeypatch)
_fake_auth(monkeypatch, rows, *, calls=None)
test_auth_failure_degrades_to_the_volume(monkeypatch, boom)
test_roster_is_cached(monkeypatch)
static double read(int32_t code)