syntheogenesis / tests /test_construct_context.py
github-actions[bot]
Deploy df060e3
7443b93
Raw
History Blame Contribute Delete
11.5 kB
"""The construct as the object every tool operates on.
THE INVERSION UNDER TEST
------------------------
A tool used to be a destination: eight routes, eight empty inputs, and a
"hand-off" that copied a string from one <textarea> into another. Nothing
carried across them and the backend never learned which project a run belonged
to, so /api/mission had to reconstruct "constructs" by grouping artifacts on a
normalised NAME STRING — which merges everything left at a default name and
splits a project the moment you rename something.
These tests pin the three properties that decide whether the replacement is
better than what it replaces, because each is a way it could be worse:
1. SIGNED OUT, NOTHING CHANGES. Public tool access was deliberate. If any of
this becomes a precondition for running a tool, the change is a
regression no matter how tidy the workspace looks.
2. THE SAME SEQUENCE IS THE SAME PROJECT. Pasting twice must not spawn a
duplicate — that is how a workspace becomes a list the user has to tidy.
3. ATTRIBUTION IS BEST-EFFORT AND NEVER FATAL. A failed cross-reference must
not fail the save the user actually asked for.
"""
import json
import pytest
from dee import auth as dee_auth
from dee import server
def _client():
app = server.create_app()
app.config.update(TESTING=True)
return app.test_client()
class _Auth:
def __init__(self, user_id=None):
self.user_id = user_id
self.anonymous = user_id is None
# --------------------------------------------------------------------------- #
# 1. Signed out, nothing changes
# --------------------------------------------------------------------------- #
def test_listing_constructs_signed_out_is_an_empty_gate_not_an_error(monkeypatch):
"""A 200 with gated:true, not a 401. The switcher has to render for a
signed-out visitor without treating them as a failure."""
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
body = _client().get("/api/constructs").get_json()
assert body == {"ok": True, "gated": True, "constructs": []}
@pytest.mark.parametrize("method,path", [
("post", "/api/constructs"),
("get", "/api/constructs/11111111-1111-1111-1111-111111111111"),
("patch", "/api/constructs/11111111-1111-1111-1111-111111111111"),
("delete", "/api/constructs/11111111-1111-1111-1111-111111111111"),
])
def test_writing_constructs_signed_out_is_refused_with_a_signin_kind(
monkeypatch, method, path):
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
cl = _client()
res = getattr(cl, method)(path, json={"sequence_dna": "ACGT" * 20})
assert res.status_code == 401
assert res.get_json().get("kind") == "signin_required"
def test_the_client_keeps_its_own_selection_when_signed_out():
"""context.js must not depend on the server for the signed-out path — the
selection lives in localStorage and still threads every view."""
with open("dee/static/context.js", encoding="utf-8") as fh:
src = fh.read()
assert "localStorage" in src
# adopt() posts, but a non-ok response must be swallowed, not thrown.
assert "if (!r.ok) return null;" in src
def test_context_never_blocks_a_tool_run():
"""Rule 2. There must be no code path where a missing construct prevents
a request — the interceptor only ever ADDS a field."""
with open("dee/static/context.js", encoding="utf-8") as fh:
src = fh.read()
assert "return nativeFetch(input, init);" in src
# Exactly ONE call site, so no early bail-out can creep in later: every
# path through the wrapper ends at the same return.
assert src.count("nativeFetch(") == 1
def test_prefill_never_clobbers_what_the_user_typed():
"""Rule 3. Overwriting a filled input is the fastest way to make the whole
idea untrustworthy."""
with open("dee/static/context.js", encoding="utf-8") as fh:
src = fh.read()
assert "if (!el || (el.value || '').trim()) return false; // never clobber" in src
# --------------------------------------------------------------------------- #
# 2. The same sequence is the same project
# --------------------------------------------------------------------------- #
def test_posting_the_same_sequence_twice_reuses_the_construct(monkeypatch):
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
existing = {"id": "abc", "name": "MC1R", "phase": "Build",
"sequence_hash": "deadbeef"}
monkeypatch.setattr(server._auth, "find_construct_by_hash",
lambda uid, h: existing)
called = {"n": 0}
def _never(*a, **k):
called["n"] += 1
return {"ok": True, "id": "new"}
monkeypatch.setattr(server._auth, "save_construct", _never)
body = _client().post("/api/constructs",
json={"sequence_dna": "ACGT" * 20}).get_json()
assert body["ok"] is True and body["reused"] is True
assert body["construct"]["id"] == "abc"
assert called["n"] == 0, "a duplicate project must not be created"
def test_a_construct_needs_a_sequence(monkeypatch):
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
res = _client().post("/api/constructs", json={"name": "no sequence"})
assert res.status_code == 400
def test_the_list_payload_does_not_ship_the_sequence(monkeypatch):
"""The switcher renders every project. Shipping the DNA with each would
make a routine dropdown megabytes."""
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
monkeypatch.setattr(server._auth, "list_constructs", lambda uid: [{
"id": "a", "name": "N", "phase": "Design", "sequence_hash": "h",
"sequence_dna": "ACGT" * 5000, "wt_protein": "MKV" * 100,
"plasmid_ids": ["p1"], "crispr_ids": [], "primer_ids": [],
}])
row = _client().get("/api/constructs").get_json()["constructs"][0]
assert "sequence_dna" not in row and "wt_protein" not in row
assert row["n_plasmids"] == 1 and row["n_crispr"] == 0
def test_the_single_get_does_ship_the_sequence(monkeypatch):
"""...because that one is the construct the user actually selected, and
the tools need it to pre-fill."""
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
monkeypatch.setattr(server._auth, "get_construct", lambda uid, cid: {
"id": cid, "name": "N", "phase": "Design",
"sequence_dna": "ACGTACGT", "wt_protein": "MKV",
})
c = _client().get(
"/api/constructs/11111111-1111-1111-1111-111111111111"
).get_json()["construct"]
assert c["sequence_dna"] == "ACGTACGT" and c["wt_protein"] == "MKV"
# --------------------------------------------------------------------------- #
# 3. Attribution is best-effort and never fatal
# --------------------------------------------------------------------------- #
def test_attach_rejects_a_bad_kind_or_id():
assert dee_auth.attach_artifact("u1", "not-a-uuid", "plasmid", "x")["ok"] is False
good = "11111111-1111-1111-1111-111111111111"
assert dee_auth.attach_artifact("u1", good, "nonsense", good)["ok"] is False
def test_phase_only_ever_moves_forward(monkeypatch):
"""Opening the CRISPR tool after logging results must not drag a project
back from Learn to Edit."""
good = "11111111-1111-1111-1111-111111111111"
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
"id": cid, "phase": "Learn", "crispr_ids": []})
seen = {}
def _upd(uid, cid, **fields):
seen.update(fields)
return {"ok": True, "construct": {}}
monkeypatch.setattr(dee_auth, "update_construct", _upd)
dee_auth.attach_artifact("u1", good, "crispr", good)
assert seen["crispr_ids"] == [good]
assert "phase" not in seen, "Learn must not regress to Edit"
def test_phase_advances_when_it_should(monkeypatch):
good = "11111111-1111-1111-1111-111111111111"
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
"id": cid, "phase": "Design", "crispr_ids": []})
seen = {}
monkeypatch.setattr(dee_auth, "update_construct",
lambda uid, cid, **f: (seen.update(f),
{"ok": True, "construct": {}})[1])
dee_auth.attach_artifact("u1", good, "crispr", good)
assert seen["phase"] == "Edit"
def test_attaching_the_same_artifact_twice_is_a_no_op(monkeypatch):
good = "11111111-1111-1111-1111-111111111111"
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
"id": cid, "phase": "Build", "plasmid_ids": [good]})
monkeypatch.setattr(dee_auth, "update_construct",
lambda *a, **k: pytest.fail("should not write"))
assert dee_auth.attach_artifact("u1", good, "plasmid", good)["already"] is True
def test_a_failing_attribution_does_not_fail_the_save(monkeypatch):
"""The artifact still exists in its own table; only the cross-reference is
missing. Losing the save instead would be strictly worse."""
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
monkeypatch.setattr(server._auth, "save_plasmid",
lambda *a, **k: {"ok": True, "id": "p-1"})
monkeypatch.setattr(server._auth, "cleanup_expired_plasmids_async",
lambda uid: None)
def _boom(*a, **k):
raise RuntimeError("supabase down")
monkeypatch.setattr(server._auth, "attach_artifact", _boom)
res = _client().post("/api/plasmid/save", json={
"name": "p", "topology": "circular", "sequence": "ACGT" * 30,
"features": [], "construct_id": "11111111-1111-1111-1111-111111111111",
})
assert res.status_code == 200 and res.get_json()["ok"] is True
def test_user_id_cannot_be_passed_as_a_field_at_all():
"""The strongest version of the check: `user_id` is a positional parameter
of update_construct, so a caller splatting a request body that contains it
gets a TypeError rather than a silent ownership change. Asserted because
it is a property of the signature that a future refactor to **kwargs would
quietly remove."""
good = "11111111-1111-1111-1111-111111111111"
with pytest.raises(TypeError):
dee_auth.update_construct("u1", good, **{"user_id": "attacker"})
def test_update_ignores_fields_the_client_should_not_write(monkeypatch):
"""Everything outside the allow-list is dropped before the row is touched
— a TTL or an id from a request body must never reach it."""
good = "11111111-1111-1111-1111-111111111111"
captured = {}
import urllib.request
class _Resp:
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self): return b"[]"
def _fake(req, timeout=0):
captured["body"] = json.loads(req.data.decode())
return _Resp()
monkeypatch.setattr(dee_auth, "SUPABASE_URL", "https://x")
monkeypatch.setattr(dee_auth, "SUPABASE_SERVICE_KEY", "k")
monkeypatch.setattr(urllib.request, "urlopen", _fake)
dee_auth.update_construct("u1", good, name="ok", expires_at="never",
id="other", sequence_hash="forged", phase="Build")
assert "expires_at" not in captured["body"]
assert "id" not in captured["body"]
assert "sequence_hash" not in captured["body"]
assert captured["body"]["name"] == "ok"
assert captured["body"]["phase"] == "Build"