1"""Per-user engine designs: working copy + microversions + releases.
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.
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.
21sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23pytest.importorskip(
"fastapi", reason=
"API tests need fastapi")
24pytest.importorskip(
"httpx", reason=
"fastapi TestClient needs httpx")
26from fastapi
import FastAPI
27from fastapi.testclient
import TestClient
35app.include_router(documents.router)
37A = {
"X-Auth-Email":
"alice@berkeley.edu"}
38B = {
"X-Auth-Email":
"bob@berkeley.edu"}
41@pytest.fixture(autouse=True)
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()
52 return TestClient(app)
56OWNER_A = {
"owner": A[
"X-Auth-Email"]}
58BASE =
"/api/engine/documents"
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
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
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
84def _find(client, headers, doc_id):
85 return next(d
for d
in client.get(BASE, headers=headers).
json()
if d[
"id"] == doc_id)
92 a_id =
_create(client, A,
"Alice design")
93 _create(client, B,
"Bob design")
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]
99 assert a_list[0][
"mine"]
is True and a_list[0][
"owner"] == A[
"X-Auth-Email"]
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
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"]
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.
118 `/{id}/share/me` is the one DELETE that survives, and it drops a share
119 grant, not a config."""
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())}
125 "/api/engine/documents/{doc_id}/share/me",
128 "/api/engine/documents/{doc_id}/checkout",
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
143 assert client.get(f
"/api/engine/documents/{doc_id}/load", headers=A).
json()[
"config"] == cfg
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
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."""
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
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
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"
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
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
198 """The safety net: an autosaved mistake is recoverable from an earlier
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}})
207 history = client.get(f
"/api/engine/documents/{doc_id}/history", headers=A).
json()
208 assert len(history) >= 2
211 f
"/api/engine/documents/{doc_id}/version/{history[-1]['versionId']}", headers=A
213 assert oldest[
"config"] == good
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"]])
226 client.delete(f
"{BASE}/{a_id}/checkout", headers=A)
227 _take(client, a_id, headers=B, params=OWNER_A)
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"}
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"
242 bob = B[
"X-Auth-Email"]
243 rec =
_share(client, a_id, [bob, bob.upper(),
" ", A[
"X-Auth-Email"]])
246 assert rec[
"sharedWith"] == [bob]
247 assert rec[
"sharedUpdatedBy"] == A[
"X-Auth-Email"]
249 assert _share(client, a_id, [])[
"sharedWith"] == []
250 assert client.get(f
"{BASE}/{a_id}/history", headers=B, params=OWNER_A).status_code == 403
254 """No owner/editor distinction: a shared editor can change the share list,
255 including removing people. Revocation is housekeeping, not a boundary."""
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"]
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()]
272 """Otherwise the config ends up in nobody's editable list."""
274 assert client.delete(f
"{BASE}/{a_id}/share/me", headers=A).status_code == 400
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"}]))
285 listed = client.get(BASE, headers=A).
json()
286 assert [d[
"id"]
for d
in listed] == [
"old"]
287 assert listed[0][
"sharedWith"] == []
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
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")
303 tree = client.get(f
"{BASE}/browse", headers=B).
json()
304 assert [g[
"owner"]
for g
in tree] == [A[
"X-Auth-Email"]]
307 assert [d[
"id"]
for d
in tree[0][
"designs"]] == [a_id]
311 a_id =
_create(client, A,
"Booster", config={
"v": 1})
313 client.post(f
"{BASE}/{a_id}/autosave", headers=A, json={
"config": {
"v": 1}})
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}
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"] == []
329 r = client.post(f
"{BASE}/copy", headers=B, json={
"owner": A[
"X-Auth-Email"],
"id":
"nope"})
330 assert r.status_code == 404
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),
360_UNSCOPED = {
"list_documents",
"browse_documents",
"create_document",
"copy_document",
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)
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}"
378@pytest.mark.parametrize("name", sorted(_DOC_SCOPED))
381 assert _call(client, name, a_id, B, OWNER_A).status_code == 403, f
"{name} leaked access"
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
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
395@pytest.mark.parametrize("owner", ["../..", ".", "", "a/b", "..", "./../etc"])
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
402 assert sorted(p.relative_to(tmp_path)
for p
in tmp_path.rglob(
"*")) == before
406 """History order has to survive coarse filesystem timestamps.
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.
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.
419 _take(client, doc_id)
421 assert client.post(f
"{BASE}/{doc_id}/autosave", headers=A,
422 json={
"config": {
"i": i}}).status_code == 200
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
428 os.utime(p, (1_700_000_000, 1_700_000_000))
430 history = client.get(f
"{BASE}/{doc_id}/history", headers=A).
json()
432 client.get(f
"{BASE}/{doc_id}/version/{v['versionId']}", headers=A).
json()[
"config"][
"i"]
433 for v
in history[:10]
435 assert seen == list(range(9, -1, -1)), f
"history out of order: {seen}"
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
455 loaded = client.get(f
"{BASE}/{doc_id}/load", headers=A).
json()
456 assert loaded[
"ui"] == ui
457 assert loaded[
"config"] == {
"combustion": {}}
461 """Every design saved before this change is a bare `{"config": ...}`, and
462 they must keep opening -- the client defaults the missing half to {}."""
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
468 loaded = client.get(f
"{BASE}/{doc_id}/load", headers=A).
json()
469 assert loaded[
"config"] == {
"spray": {}}
470 assert loaded.get(
"ui") == {}
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})
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
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"}}})
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
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)