Spaces:
Running
Running
File size: 11,468 Bytes
7443b93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | """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"
|