STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_documents.py
Go to the documentation of this file.
1"""Per-user engine designs: working copy + microversions + releases.
2
3The versioned-document API (/api/engine/documents) mirrors the pid-designer
4model: an autosaved working copy, throttled microversions, and immutable named
5releases. Identity is X-Auth-Email with a `local` dev fallback.
6
7Designs are shared: one lives in its creator's folder but is editable by anyone
8on its `sharedWith` list, and readable/copyable by anyone at all. So the
9invariant under test is no longer "users cannot see each other" -- it is that
10`?owner=` grants exactly the access the share list says it does. The 403/404
11matrix below walks the router's own route table, so an endpoint added without
12going through `_resolve_doc` fails here rather than shipping a hole.
13"""
14
15import json
16import os
17import sys
18
19import pytest
20
21sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
23pytest.importorskip("fastapi", reason="API tests need fastapi")
24pytest.importorskip("httpx", reason="fastapi TestClient needs httpx")
25
26from fastapi import FastAPI # noqa: E402
27from fastapi.testclient import TestClient # noqa: E402
28
29from backend.routers import documents # noqa: E402
30
31# Just the documents router, not backend.main: importing the whole app drags in
32# the native engine kernel and the optimizer, which this suite has no use for.
33# It is the same router the app mounts, so the request path under test is real.
34app = FastAPI()
35app.include_router(documents.router)
36
37A = {"X-Auth-Email": "alice@berkeley.edu"}
38B = {"X-Auth-Email": "bob@berkeley.edu"}
39
40
41@pytest.fixture(autouse=True)
42def _isolate(tmp_path, monkeypatch):
43 """Fresh data root per test; force a microversion on every autosave (no
44 throttle) and clear the in-process throttle clock so tests don't interfere."""
45 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
46 monkeypatch.setattr(documents.store, "micro_interval", 0)
47 documents.store.last_micro.clear()
48
49
50@pytest.fixture
51def client():
52 return TestClient(app)
53
54
55#: `?owner=alice`, for a request acting on one of Alice's configs.
56OWNER_A = {"owner": A["X-Auth-Email"]}
57
58BASE = "/api/engine/documents"
59
60
61def _create(client, headers, name="Baseline", config=None):
62 r = client.post(BASE, headers=headers,
63 json={"name": name, "config": config or {"combustion": {}}})
64 assert r.status_code == 200, r.text
65 return r.json()["id"]
66
67
68def _share(client, doc_id, emails, *, headers=A, params=None):
69 r = client.put(f"{BASE}/{doc_id}/share", headers=headers,
70 params=params if params is not None else {}, json={"sharedWith": emails})
71 assert r.status_code == 200, r.text
72 return r.json()
73
74
75def _take(client, doc_id, headers=A, params=None):
76 """Content writes need the checkout now. Explicit in every test that saves,
77 rather than hidden in a fixture -- taking it is part of the API contract."""
78 r = client.post(f"{BASE}/{doc_id}/checkout", headers=headers,
79 params=params if params is not None else {})
80 assert r.status_code == 200, r.text
81 return r.json()
82
83
84def _find(client, headers, doc_id):
85 return next(d for d in client.get(BASE, headers=headers).json() if d["id"] == doc_id)
86
87
88# ── CRUD + isolation ─────────────────────────────────────────────────────────
89
90
92 a_id = _create(client, A, "Alice design")
93 _create(client, B, "Bob design")
94
95 a_list = client.get("/api/engine/documents", headers=A).json()
96 b_list = client.get("/api/engine/documents", headers=B).json()
97 assert [d["id"] for d in a_list] == [a_id]
98 assert a_id not in [d["id"] for d in b_list] # B never sees A's doc
99 assert a_list[0]["mine"] is True and a_list[0]["owner"] == A["X-Auth-Email"]
100
101 _share(client, a_id, [B["X-Auth-Email"]])
102 b_list = client.get("/api/engine/documents", headers=B).json()
103 entry = next(d for d in b_list if d["id"] == a_id)
104 assert entry["owner"] == A["X-Auth-Email"] and entry["mine"] is False
105
106
107def test_rename(client):
108 doc_id = _create(client, A)
109 r = client.patch(f"/api/engine/documents/{doc_id}", headers=A, json={"name": "Renamed"})
110 assert r.status_code == 200 and r.json()["name"] == "Renamed"
111 assert [c["name"] for c in client.get("/api/engine/documents", headers=A).json()] == ["Renamed"]
112
113
115 """Configs are never deleted -- shared configs made it too easy to destroy
116 someone else's work. Cleanup is an admin operation on the volume.
117
118 `/{id}/share/me` is the one DELETE that survives, and it drops a share
119 grant, not a config."""
120 doc_id = _create(client, A)
121 assert client.delete(f"/api/engine/documents/{doc_id}", headers=A).status_code == 405
122 deletes = {r.path for r in documents.router.routes
123 if "DELETE" in getattr(r, "methods", set())}
124 assert deletes == {
125 "/api/engine/documents/{doc_id}/share/me",
126 # Releasing a checkout is the other DELETE, and it drops a write token,
127 # not a design.
128 "/api/engine/documents/{doc_id}/checkout",
129 }
130
131
132# ── working copy + microversions ─────────────────────────────────────────────
133
134
136 doc_id = _create(client, A)
137 _take(client, doc_id)
138 cfg = {"combustion": {"efficiency": {"c_star": 0.95}}, "note": "v1"}
139 assert client.post(f"/api/engine/documents/{doc_id}/autosave",
140 headers=A, json={"config": cfg}).json()["micro"] is True
141
142 # /load returns the freshest working copy.
143 assert client.get(f"/api/engine/documents/{doc_id}/load", headers=A).json()["config"] == cfg
144
145 # The autosave recorded a microversion; fetch it back verbatim.
146 history = client.get(f"/api/engine/documents/{doc_id}/history", headers=A).json()
147 assert len(history) >= 1
148 vid = history[0]["versionId"]
149 snap = client.get(f"/api/engine/documents/{doc_id}/version/{vid}", headers=A).json()
150 assert snap["config"] == cfg
151
152
154 """Bob naming Alice's config gets 404 without ?owner= (no such config of his
155 own) and 403 with it (hers, not shared) -- never her data."""
156 a_id = _create(client, A)
157 client.post(f"/api/engine/documents/{a_id}/autosave", headers=A,
158 json={"config": {"secret": 1}})
159 assert client.get(f"/api/engine/documents/{a_id}/history", headers=B).status_code == 404
160 assert client.get(f"/api/engine/documents/{a_id}/history",
161 headers=B, params=OWNER_A).status_code == 403
162
163
165 a_id = _create(client, A)
166 _take(client, a_id)
167 client.post(f"/api/engine/documents/{a_id}/autosave", headers=A,
168 json={"config": {"shared": 1}})
169 _share(client, a_id, [B["X-Auth-Email"]])
170 history = client.get(f"/api/engine/documents/{a_id}/history",
171 headers=B, params=OWNER_A).json()
172 assert len(history) >= 1
173
174
175# ── releases ─────────────────────────────────────────────────────────────────
176
177
179 doc_id = _create(client, A)
180 _take(client, doc_id)
181 cfg = {"combustion": {}, "v": 1}
182 r = client.post(f"/api/engine/documents/{doc_id}/release",
183 headers=A, json={"label": "0.1", "config": cfg})
184 assert r.status_code == 200 and r.json()["label"] == "0.1"
185
186 # Re-releasing the same label is a conflict -- releases are immutable.
187 dup = client.post(f"/api/engine/documents/{doc_id}/release",
188 headers=A, json={"label": "0.1", "config": {"v": 2}})
189 assert dup.status_code == 409
190
191 assert "0.1" in [r["label"] for r in
192 client.get(f"/api/engine/documents/{doc_id}/releases", headers=A).json()]
193 got = client.get(f"/api/engine/documents/{doc_id}/release/0.1", headers=A).json()
194 assert got["config"] == cfg # the original, not the rejected v:2
195
196
198 """The safety net: an autosaved mistake is recoverable from an earlier
199 microversion."""
200 doc_id = _create(client, A)
201 _take(client, doc_id)
202 good = {"combustion": {}, "thrust": 1000}
203 client.post(f"/api/engine/documents/{doc_id}/autosave", headers=A, json={"config": good})
204 client.post(f"/api/engine/documents/{doc_id}/autosave", headers=A,
205 json={"config": {"combustion": {}, "thrust": 0}}) # the "mistake"
206
207 history = client.get(f"/api/engine/documents/{doc_id}/history", headers=A).json()
208 assert len(history) >= 2
209 # The oldest snapshot still holds the good state.
210 oldest = client.get(
211 f"/api/engine/documents/{doc_id}/version/{history[-1]['versionId']}", headers=A
212 ).json()
213 assert oldest["config"] == good
214
215
216# ── sharing ──────────────────────────────────────────────────────────────────
217
218
220 """A shared editor edits the *same* config, not a copy -- so the bytes must
221 land in Alice's folder and Alice must see the change."""
222 a_id = _create(client, A, "Booster")
223 _share(client, a_id, [B["X-Auth-Email"]])
224 # Creating a design checks it out to its creator, so Alice hands it over
225 # before Bob can take it -- which is the real handover this test is about.
226 client.delete(f"{BASE}/{a_id}/checkout", headers=A)
227 _take(client, a_id, headers=B, params=OWNER_A) # B is the one editing here
228
229 r = client.post(f"{BASE}/{a_id}/autosave", headers=B, params=OWNER_A,
230 json={"config": {"by": "bob"}})
231 assert r.status_code == 200
232 assert (tmp_path / "alice@berkeley.edu" / "engine" / a_id / "current.json").is_file()
233 assert not (tmp_path / "bob@berkeley.edu" / "engine" / a_id).exists()
234 assert client.get(f"{BASE}/{a_id}/load", headers=A).json()["config"] == {"by": "bob"}
235
236 r = client.patch(f"{BASE}/{a_id}", headers=B, params=OWNER_A, json={"name": "Bob's edit"})
237 assert r.status_code == 200 and r.json()["name"] == "Bob's edit"
238
239
241 a_id = _create(client, A)
242 bob = B["X-Auth-Email"]
243 rec = _share(client, a_id, [bob, bob.upper(), " ", A["X-Auth-Email"]])
244 # Deduped on the path slug, the owner filtered out (they are implicitly and
245 # unremovably an editor), blanks dropped.
246 assert rec["sharedWith"] == [bob]
247 assert rec["sharedUpdatedBy"] == A["X-Auth-Email"]
248
249 assert _share(client, a_id, [])["sharedWith"] == []
250 assert client.get(f"{BASE}/{a_id}/history", headers=B, params=OWNER_A).status_code == 403
251
252
254 """No owner/editor distinction: a shared editor can change the share list,
255 including removing people. Revocation is housekeeping, not a boundary."""
256 a_id = _create(client, A)
257 _share(client, a_id, [B["X-Auth-Email"]])
258 rec = _share(client, a_id, ["carol@berkeley.edu"], headers=B, params=OWNER_A)
259 assert rec["sharedWith"] == ["carol@berkeley.edu"]
260 assert rec["sharedUpdatedBy"] == B["X-Auth-Email"]
261
262
264 a_id = _create(client, A)
265 _share(client, a_id, [B["X-Auth-Email"], "carol@berkeley.edu"])
266 assert client.delete(f"{BASE}/{a_id}/share/me", headers=B, params=OWNER_A).status_code == 200
267 assert _find(client, A, a_id)["sharedWith"] == ["carol@berkeley.edu"]
268 assert a_id not in [d["id"] for d in client.get(BASE, headers=B).json()]
269
270
272 """Otherwise the config ends up in nobody's editable list."""
273 a_id = _create(client, A)
274 assert client.delete(f"{BASE}/{a_id}/share/me", headers=A).status_code == 400
275
276
278 """Every config created before sharing existed lacks the key. A missing
279 `sharedWith` is an empty list, never an error."""
280 index = tmp_path / "alice@berkeley.edu" / "engine" / "index.json"
281 index.parent.mkdir(parents=True, exist_ok=True)
282 index.write_text(json.dumps([{"id": "old", "name": "Old", "createdAt": "2020-01-01",
283 "updatedAt": "2020-01-01"}]))
284
285 listed = client.get(BASE, headers=A).json()
286 assert [d["id"] for d in listed] == ["old"]
287 assert listed[0]["sharedWith"] == []
288 # The empty payload is `{config, ui}` now: a record with no stored working
289 # copy loads as both halves empty, not just the config.
290 assert client.get(f"{BASE}/old/load", headers=A).json() == {"config": {}, "ui": {}}
291 assert client.get(f"{BASE}/old/history", headers=B, params=OWNER_A).status_code == 403
292
293
294# ── browse + copy: anyone may look, anyone may take a copy ───────────────────
295
296
298 a_id = _create(client, A, "Alice design")
299 shared_id = _create(client, A, "Shared design")
300 _share(client, shared_id, [B["X-Auth-Email"]])
301 _create(client, B, "Bob design")
302
303 tree = client.get(f"{BASE}/browse", headers=B).json()
304 assert [g["owner"] for g in tree] == [A["X-Auth-Email"]]
305 # Alice's unshared config only: the shared one is editable (so it is in the
306 # editable list), and Bob's own never shows up in the view-only tree.
307 assert [d["id"] for d in tree[0]["designs"]] == [a_id]
308
309
311 a_id = _create(client, A, "Booster", config={"v": 1})
312 _take(client, a_id)
313 client.post(f"{BASE}/{a_id}/autosave", headers=A, json={"config": {"v": 1}})
314
315 copy = client.post(f"{BASE}/copy", headers=B,
316 json={"owner": A["X-Auth-Email"], "id": a_id}).json()
317 assert copy["name"] == f"Booster (copy of {A['X-Auth-Email']})"
318 assert copy["sharedWith"] == []
319 assert client.get(f"{BASE}/{copy['id']}/load", headers=B).json()["config"] == {"v": 1}
320
321 # Editing the copy must not touch the original, nor grant access back to it.
322 _take(client, copy["id"], headers=B)
323 client.post(f"{BASE}/{copy['id']}/autosave", headers=B, json={"config": {"v": 2}})
324 assert client.get(f"{BASE}/{a_id}/load", headers=A).json()["config"] == {"v": 1}
325 assert _find(client, A, a_id)["sharedWith"] == []
326
327
329 r = client.post(f"{BASE}/copy", headers=B, json={"owner": A["X-Auth-Email"], "id": "nope"})
330 assert r.status_code == 404
331
332
333# ── the access matrix: the guard that must not develop a hole ────────────────
334
335
336#: Every design-scoped route, as (method, path-suffix, json body). A route added
337#: without an entry fails test_every_doc_scoped_route_is_listed below.
338_DOC_SCOPED = {
339 "rename_document": ("PATCH", "", {"name": "x"}),
340 "share_document": ("PUT", "/share", {"sharedWith": []}),
341 "leave_document": ("DELETE", "/share/me", None),
342 "take_checkout": ("POST", "/checkout", None),
343 "release_checkout": ("DELETE", "/checkout", None),
344 "release_checkout_beacon": ("POST", "/checkout/release", None),
345 "beat_checkout": ("POST", "/checkout/beat", None),
346 "get_checkout": ("GET", "/checkout", None),
347 "load_document": ("GET", "/load", None),
348 "autosave_document": ("POST", "/autosave", {"config": {"x": 1}}),
349 "flush_document": ("POST", "/flush", {"config": {"x": 1}}),
350 "get_history": ("GET", "/history", None),
351 "get_version": ("GET", "/version/deadbeef", None),
352 "create_release": ("POST", "/release", {"label": "0.1"}),
353 "list_releases": ("GET", "/releases", None),
354 "get_release": ("GET", "/release/0.1", None),
355}
356
357#: Not design-scoped: no doc id, or exists precisely to reach configs you cannot edit.
358#: `rescue_document` sits with copy: it reads only the source's name and writes
359#: a new design into the caller's own list, never into the source.
360_UNSCOPED = {"list_documents", "browse_documents", "create_document", "copy_document",
361 "rescue_document"}
362
363
364def _call(client, name, doc_id, headers, params):
365 method, suffix, body = _DOC_SCOPED[name]
366 return client.request(method, f"{BASE}/{doc_id}{suffix}",
367 headers=headers, params=params, json=body)
368
369
371 """Pins the matrix to the real route table. Add an endpoint and this fails
372 until you say which side of the access boundary it sits on."""
373 handlers = {r.endpoint.__name__ for r in documents.router.routes if hasattr(r, "endpoint")}
374 covered = set(_DOC_SCOPED) | _UNSCOPED
375 assert handlers == covered, f"unclassified endpoints: {handlers ^ covered}"
376
377
378@pytest.mark.parametrize("name", sorted(_DOC_SCOPED))
380 a_id = _create(client, A)
381 assert _call(client, name, a_id, B, OWNER_A).status_code == 403, f"{name} leaked access"
382
383
384@pytest.mark.parametrize("name", sorted(_DOC_SCOPED))
386 """Dropping `?owner=` must 404, never silently create an orphan working copy
387 in the caller's own folder -- a failure `/flush` (a sendBeacon) could not
388 even report."""
389 a_id = _create(client, A)
390 before = sorted(p.relative_to(tmp_path) for p in tmp_path.rglob("*"))
391 assert _call(client, name, a_id, B, {}).status_code == 404, f"{name} did not 404"
392 assert sorted(p.relative_to(tmp_path) for p in tmp_path.rglob("*")) == before
393
394
395@pytest.mark.parametrize("owner", ["../..", ".", "", "a/b", "..", "./../etc"])
396def test_owner_param_cannot_escape_the_root(client, tmp_path, owner):
397 a_id = _create(client, A)
398 before = sorted(p.relative_to(tmp_path) for p in tmp_path.rglob("*"))
399 assert client.get(f"{BASE}/{a_id}/load", headers=B, params={"owner": owner}).status_code == 404
400 # Both the scan and the resolve must be read-only: no folder conjured for a
401 # user who does not exist.
402 assert sorted(p.relative_to(tmp_path) for p in tmp_path.rglob("*")) == before
403
404
406 """History order has to survive coarse filesystem timestamps.
407
408 Microversions are the only recovery path now that delete is gone, so
409 "restore the previous version" handing back the wrong snapshot is data loss.
410 Two snapshots written back-to-back can share an mtime tick, and ordering
411 must not degrade to arbitrary when they do.
412
413 Rather than race the filesystem and hope, this forces the condition: every
414 version file is stamped with the *same* mtime, so the order can only come
415 from the ids themselves. With random ids this fails every run; it is the
416 nanosecond prefix in `snapshot_micro` that makes it pass.
417 """
418 doc_id = _create(client, A)
419 _take(client, doc_id)
420 for i in range(10):
421 assert client.post(f"{BASE}/{doc_id}/autosave", headers=A,
422 json={"config": {"i": i}}).status_code == 200
423
424 versions = tmp_path / "alice@berkeley.edu" / "engine" / doc_id / "versions"
425 stamped = [p for p in versions.glob("*.json") if not p.name.startswith(".")]
426 assert len(stamped) >= 10
427 for p in stamped:
428 os.utime(p, (1_700_000_000, 1_700_000_000)) # one shared mtime tick
429
430 history = client.get(f"{BASE}/{doc_id}/history", headers=A).json()
431 seen = [
432 client.get(f"{BASE}/{doc_id}/version/{v['versionId']}", headers=A).json()["config"]["i"]
433 for v in history[:10]
434 ]
435 assert seen == list(range(9, -1, -1)), f"history out of order: {seen}"
436
437
438# ── the `ui` half of the payload ─────────────────────────────────────────────
439#
440# A design is `{config, ui}`. `ui` carries the authored state that has no home
441# in PintleEngineConfig -- controller commands, time-series pressure profiles,
442# Layer 4's vehicle, the layer run settings, the plot definition. Before it
443# existed none of that survived a reload, which is most of what "I changed it
444# and it didn't save" meant. See frontend/src/lib/designState.ts.
445
446
448 doc_id = _create(client, A)
449 _take(client, doc_id)
450 ui = {"controller": {"duration": "7.5"}, "plotter": {"xAxis": "time"}}
451 r = client.post(f"{BASE}/{doc_id}/autosave", headers=A,
452 json={"config": {"combustion": {}}, "ui": ui})
453 assert r.status_code == 200, r.text
454
455 loaded = client.get(f"{BASE}/{doc_id}/load", headers=A).json()
456 assert loaded["ui"] == ui
457 assert loaded["config"] == {"combustion": {}}
458
459
461 """Every design saved before this change is a bare `{"config": ...}`, and
462 they must keep opening -- the client defaults the missing half to {}."""
463 doc_id = _create(client, A)
464 _take(client, doc_id)
465 r = client.post(f"{BASE}/{doc_id}/autosave", headers=A, json={"config": {"spray": {}}})
466 assert r.status_code == 200, r.text
467
468 loaded = client.get(f"{BASE}/{doc_id}/load", headers=A).json()
469 assert loaded["config"] == {"spray": {}}
470 assert loaded.get("ui") == {}
471
472
474 doc_id = _create(client, A)
475 _take(client, doc_id)
476 ui = {"timeseries": {"inputMode": "segments"}}
477 client.post(f"{BASE}/{doc_id}/autosave", headers=A,
478 json={"config": {"combustion": {}}, "ui": ui})
479
480 r = client.post(f"{BASE}/{doc_id}/release", headers=A, json={"label": "0.1"})
481 assert r.status_code == 200, r.text
482 published = client.get(f"{BASE}/{doc_id}/release/0.1", headers=A).json()
483 assert published["ui"] == ui
484
485
487 doc_id = _create(client, A)
488 _take(client, doc_id)
489 first = {"controller": {"duration": "1"}}
490 client.post(f"{BASE}/{doc_id}/autosave", headers=A,
491 json={"config": {"combustion": {}}, "ui": first})
492 client.post(f"{BASE}/{doc_id}/autosave", headers=A,
493 json={"config": {"combustion": {}}, "ui": {"controller": {"duration": "2"}}})
494
495 history = client.get(f"{BASE}/{doc_id}/history", headers=A).json()
496 oldest = history[-1]["versionId"]
497 restored = client.get(f"{BASE}/{doc_id}/version/{oldest}", headers=A).json()
498 assert restored["ui"] == first
a class to store JSON values
Definition json.hpp:19401
test_any_editor_may_reshare(client)
test_copy_of_a_missing_config_is_404(client)
test_ui_round_trips_through_a_microversion(client)
_isolate(tmp_path, monkeypatch)
test_ui_round_trips_through_a_release(client)
test_doc_scoped_route_forbids_an_unshared_owner(client, name)
test_create_lists_and_isolates_until_shared(client)
_take(client, doc_id, headers=A, params=None)
_share(client, doc_id, emails, *, headers=A, params=None)
test_share_replaces_the_whole_list_and_drops_the_owner(client)
test_delete_is_gone(client)
test_share_grants_edit_and_writes_into_the_owners_folder(client, tmp_path)
test_history_needs_a_share(client)
test_legacy_records_without_sharedwith_still_work(client, tmp_path)
test_restore_returns_older_state(client)
_call(client, name, doc_id, headers, params)
_create(client, headers, name="Baseline", config=None)
test_copy_is_independent_and_needs_no_share(client)
test_ui_is_stored_and_returned_alongside_the_config(client)
test_microversion_history_is_in_write_order(client, tmp_path)
test_browse_groups_by_owner_and_hides_what_you_can_edit(client)
test_history_readable_once_shared(client)
test_doc_scoped_route_404s_a_foreign_id_without_owner(client, tmp_path, name)
test_leave_removes_only_yourself(client)
test_every_doc_scoped_route_is_listed()
_find(client, headers, doc_id)
test_owner_cannot_leave_their_own_config(client)
test_a_design_stored_before_ui_existed_still_loads(client, tmp_path)
test_release_is_immutable_and_listed(client)
test_autosave_load_and_history(client)
test_owner_param_cannot_escape_the_root(client, tmp_path, owner)