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"