File size: 17,298 Bytes
e640531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581a0af
 
e640531
 
581a0af
e640531
581a0af
e640531
581a0af
e640531
581a0af
e640531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55c3c21
 
e640531
 
 
55c3c21
 
 
 
 
 
 
 
e640531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c91e4c6
 
 
e640531
 
c91e4c6
e640531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618e46c
e640531
 
 
 
 
 
 
 
 
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
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
#!/usr/bin/env python3
"""Checks for the code and data link policy (2026-09-28). Offline: no network,
no mail, no pings; every write lands in one temporary directory that is
removed at the end, pass or fail. Run from the repo root with the venv:
    .venv/bin/python intake/scripts/test_code_data_link.py

Covers: the claim check on real papers and on made-up sentences; the form
answer through the real /api/submit handler (signed like the proxy signs it);
the worker holding a paper before the panel and the curator override; the
revise email the hold produces; the accept guard; the Zenodo relation; the
registry field.
"""
from __future__ import annotations

import glob
import hashlib
import hmac
import json
import pathlib
import shutil
import sys
import tempfile
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))

failures: list[str] = []


def check(cond: bool, name: str) -> None:
    print(("  ok   " if cond else "  FAIL ") + name)
    if not cond:
        failures.append(name)


PAPER_PDF = Path.home() / "icsac-submissions" / "ICSAC-SUB-00008" / "paper.pdf"
PAPERS = Path.home() / "Desktop" / "icsac" / "icsacinstitute.org" / "public" / "papers"
TEST_ORCID = "0000-0002-1825-0097"

tmp = Path(tempfile.mkdtemp(prefix="codelink-"))
real_home = pathlib.Path.home
try:
    import code_availability as ca
    import submission_intake as ingest

    # ── 1. the claim check ───────────────────────────────────────────────────
    print("1. claim check")
    vim_text = ingest.extract_pdf_text(str(PAPER_PDF))
    r = ca.assess({}, vim_text)
    check(r["missing_link"], "the paper: claims code/data, no link anywhere -> held")
    check(any("accompanying archive" in c for c in r["claims"]), "the paper: the archive sentence is quoted")
    link = "https://github.com/example/generator"
    check(not ca.assess({"code_data": {"available": True, "url": link}}, vim_text)["missing_link"],
          "the paper + form link -> not held")
    check(not ca.assess({"related_identifiers": [{"identifier": link, "relation": "isSupplementedBy"}]},
                        vim_text)["missing_link"], "the paper + 'is supplemented by' identifier -> not held")
    check(not ca.assess({"related_identifiers": [{"identifier": link, "relation": "isSupplementTo"}]},
                        vim_text)["missing_link"], "the paper + older 'is supplement to' identifier -> not held")
    check(ca.assess({"related_identifiers": [{"identifier": link, "relation": "cites"}]},
                    vim_text)["missing_link"], "the paper + a 'cites' identifier -> still held")

    na = "Methods.\nWe prove a theorem.\n\nData availability: Not applicable.\n\nReferences\nSmith, J. (2020)."
    check(ca.find_claims(ca.body_text(na)) == [], "'Data availability: Not applicable.' is no claim")
    none = "Data availability statement\nNo new data were generated or analysed in this study."
    check(ca.find_claims(none) == [], "'No new data were generated' is no claim")
    plain = "The data show a clear trend, and the code of conduct was followed. Figure 2 plots the data."
    check(ca.find_claims(plain) == [], "ordinary mentions of data and code are no claim")
    req = "Results.\nData are available on request from the author.\n"
    check(ca.assess({}, req)["missing_link"], "'available on request' with no link -> held")
    body_link = "Code availability. The scripts are available at https://github.com/a/b for anyone."
    check(not ca.assess({}, body_link)["missing_link"], "claim with a repository link in the body -> not held")
    refs_only = ("Code availability. The code is available from the author.\n\nReferences\n"
                 "Doe, A. (2021). Tool. https://github.com/doe/tool\n")
    check(ca.assess({}, refs_only)["missing_link"], "a link only in the reference list does not count")
    gap = "Complete source code and validated data files are available in the GitHub Repository."
    check(len(ca.find_claims(gap)) == 1, "words between 'code' and 'are available' still read as a claim")
    for p in sorted(glob.glob(str(PAPERS / "*.pdf"))):
        if p.endswith("-icsac.pdf") or not Path(p).stem.isdigit():
            continue   # founding papers are the Zenodo-record PDFs; intake papers below
        rr = ca.assess({}, ingest.extract_pdf_text(p))
        check(not rr["missing_link"], f"founding paper {Path(p).stem}: not held "
              f"({len(rr['claims'])} claim(s), {len(rr['links']['in_paper'])} link(s))")
    # The first intake paper (2026-09-29): its Declarations promise an archive with no
    # link in the text; the link came from the author's reply and rides on code_data.
    vim = PAPERS / "ICSAC-SUB-00008.pdf"
    if vim.exists():
        vt = ingest.extract_pdf_text(str(vim))
        check(ca.assess({}, vt)["missing_link"], "the published paper on its text alone: held (the claim has no link)")
        check(not ca.assess({"code_data": {"available": True, "url": "https://doi.org/10.5281/zenodo.23042786"}}, vt)["missing_link"],
              "the published paper with its author's archive link: not held")

    # ── 2. the form answer through the real handler ──────────────────────────
    print("2. /api/submit (T2, signed like the proxy)")
    import httpx
    from fastapi.testclient import TestClient
    from intake import intake_server as iss
    iss.HMAC_SECRET = b"codelink-test-secret"
    iss.TEST_ORCID_WHITELIST = frozenset({TEST_ORCID})
    subs = tmp / "subs"
    iss.SUBMISSIONS_ROOT = subs
    iss.QUEUE_DIR = subs / "queue"
    iss.COUNTER_FILE = subs / ".counter"
    iss.TEST_SUBMISSIONS_ROOT = subs / "test"
    iss.TEST_QUEUE_DIR = subs / "test" / "queue"
    audit_iss: list[dict] = []
    iss._audit_append = lambda entry, test_mode=False: audit_iss.append(entry)
    _n = iter(range(1790000000, 1790000100))
    iss._allocate_test_sub_id = lambda: f"ICSAC-SUB-TEST-{next(_n)}"
    client = TestClient(iss.app)
    pdf_bytes = PAPER_PDF.read_bytes()

    def submit(**extra) -> tuple[int, dict]:
        data = {
            "mode": "upload", "name": "Test Author", "email": "author@example.com",
            "orcid": TEST_ORCID, "coi": "true", "exclusivity_acknowledged": "true",
            "deposit_consent": "true", "title": "A test paper on boundaries",
            "abstract": "An abstract long enough to pass the fifty-character minimum for uploads.",
            "keywords": "testing, boundaries", "license": "cc-by-4.0", "resource_type": "preprint",
            "publication_date": "2026-09-28", "subject": "", "funding": "",
            "creators": json.dumps([{"name": "Test Author", "orcid": TEST_ORCID}]),
            "related_identifiers": "[]", **extra,
        }
        req = httpx.Request("POST", "http://intake/api/submit", data=data,
                            files={"pdf": ("paper.pdf", pdf_bytes, "application/pdf")})
        body = req.read()
        ts = str(int(time.time()))
        # Signature v2 (2026-09-29): ts, ORCID, name (none sent), tier, then the body.
        sig = hmac.new(iss.HMAC_SECRET, f"icsac-v2\n{ts}\n{TEST_ORCID}\n\nt2\n".encode() + body,
                       hashlib.sha256).hexdigest()
        resp = client.post("/api/submit", content=body, headers={
            "content-type": req.headers["content-type"],
            "x-icsac-signature": f"v2={sig}", "x-icsac-timestamp": ts,
            "x-icsac-auth-orcid": TEST_ORCID, "x-icsac-test-tier": "t2",
        })
        try:
            return resp.status_code, resp.json()
        except Exception:
            return resp.status_code, {"raw": resp.text[:300]}

    def record(resp: dict) -> dict:
        sid = resp.get("sub_id") or resp.get("id") or ""
        return json.loads((subs / "test" / sid / "submission.json").read_text()) if sid else {}

    st, resp = submit(code_data_available="yes", code_data_url="https://github.com/example/generator")
    check(st in (200, 202) and record(resp).get("code_data") == {"available": True, "url": "https://github.com/example/generator"},
          f"yes + link -> stored ({st})")
    linked_id = resp.get("sub_id", "")
    st, resp = submit(code_data_available="yes", code_data_url="")
    check(st == 400 and "link" in json.dumps(resp), f"yes without a link -> 400 ({st})")
    for bad in ("ftp://example.org/x", "javascript:alert(1)", "https://github.com/a b",
                "http://localhost/x", "https://user@example.org/x", "https://example.org/" + "a" * 490):
        st, resp = submit(code_data_available="yes", code_data_url=bad)
        check(st == 400, f"bad link refused: {bad[:40]} ({st})")
    st, resp = submit(code_data_available="maybe")
    check(st == 400, f"an answer other than yes/no -> 400 ({st})")
    st, resp = submit(code_data_available="no", code_data_url="https://github.com/example/x")
    check(st in (200, 202) and record(resp).get("code_data") == {"available": False, "url": None},
          f"no -> stored as no, stray link dropped ({st})")
    st, resp = submit()
    held_id = resp.get("sub_id", "")
    check(st in (200, 202) and record(resp).get("code_data") == {"available": None, "url": None},
          f"no answer (older form) -> stored as unknown ({st})")
    st, resp = submit()
    held2_id = resp.get("sub_id", "")
    check("isSupplementedBy" in iss.RELATION_TYPES, "'is supplemented by' is an accepted relation")
    st, resp = submit(related_identifiers=json.dumps([{"identifier": "https://github.com/a/b",
                                                       "relation": "isSupplementedBy"}]))
    check(st in (200, 202), f"a related identifier 'is supplemented by' is accepted ({st})")
    check(all(e.get("event") != "code_data_link_missing" for e in audit_iss), "intake itself holds nothing")

    # ── 3. the worker: hold before the panel, override, linked paper ─────────
    print("3. worker")
    from intake import submission_worker as worker
    worker.SUBMISSIONS_ROOT = subs
    worker.QUEUE_DIR = subs / "queue"
    worker.TEST_SUBMISSIONS_ROOT = subs / "test"
    worker.TEST_QUEUE_DIR = subs / "test" / "queue"
    worker.TEST_REVIEWS_DIR = tmp / "reviews-test"
    audit_w: list[dict] = []
    worker._audit = lambda entry, test_mode=False: audit_w.append(entry)
    alerts: list = []
    worker._alert_remote = lambda *a, **k: alerts.append(a)
    pings: list = []
    worker.notify.send_telegram = lambda *a, **k: pings.append(a)
    worker.notify.send_to_curator = lambda *a, **k: pings.append(a)
    panel_calls: list[str] = []

    def fake_panel(review_data):
        panel_calls.append(review_data.get("title", ""))
        return "", {"recommendation": "PAUSED_AI_FAILURE"}
    worker.review.review_paper = fake_panel

    def state(sid: str) -> dict:
        return json.loads((subs / "test" / sid / "state.json").read_text())

    worker._process_locked(held_id)
    s = state(held_id)
    check(s.get("state") == "awaiting_decision" and s.get("pending_recommendation") == "REVISE_AND_RESUBMIT"
          and s.get("precheck") == "code_data_link_missing", "no link: held for a revise before the panel")
    check(panel_calls == [], "no link: the panel did not run")
    cda = json.loads((subs / "test" / held_id / "code_availability.json").read_text())
    check(cda["missing_link"] and cda["claims"], "no link: code_availability.json records the claims")
    check(any(e.get("event") == "code_data_link_missing" for e in audit_w), "no link: audited")
    check(pings == [] and alerts == [], "T2: no curator ping, no alert")

    worker._process_locked(linked_id)
    check(panel_calls == ["A test paper on boundaries"], "form link: the panel runs")
    check(state(linked_id).get("state") == "paused_panel_failure", "form link: normal path continues")

    from intake import code_check_override as cco
    rc = cco.main(["code-check-override.sh", held_id])
    s = state(held_id)
    check(rc == 0 and s.get("code_check_override") and s.get("precheck") == "overridden",
          "override: recorded, precheck cleared")
    check((subs / "test" / "queue" / held_id).exists(), "override: queued again")
    check(cco.main(["code-check-override.sh", held_id]) == 3, "override twice: refused")
    worker._process_locked(held_id)
    check(len(panel_calls) == 2, "override: the panel runs on the next pass")

    real_assess = worker.code_availability.assess
    def broken(*a, **k):
        raise RuntimeError("simulated check failure")
    worker.code_availability.assess = broken
    st_, resp_ = submit()
    broken_id = resp_.get("sub_id", "")
    worker._process_locked(broken_id)
    worker.code_availability.assess = real_assess
    check(len(panel_calls) == 3, "a broken check fails open: the panel still runs")
    check(any(e.get("event") == "code_check_failed" for e in audit_w), "a broken check is audited")
    check(pings == [] and alerts == [], "T2: a broken check pings no one (test tier)")

    # ── 4. decisions on a held paper: the revise email, the accept guard ─────
    print("4. decisions")
    worker._process_locked(held2_id)
    check(state(held2_id).get("precheck") == "code_data_link_missing", "second paper held")
    from intake import apply_decision as ad
    ad.SUBMISSIONS_ROOT = subs
    ad.TEST_SUBMISSIONS_ROOT = subs / "test"
    ad._audit = lambda entry, test_mode=False: audit_w.append(entry)
    ad.notify.send_to_curator = lambda *a, **k: pings.append(a)
    pathlib.Path.home = classmethod(lambda cls: tmp / "home")
    rc = ad.main(["apply_decision.py", held2_id, "accept"])
    check(rc == 3 and not state(held2_id).get("decision"), "accept on a held paper: refused, nothing recorded")
    rc = ad.main(["apply_decision.py", held2_id, "revise"])
    pathlib.Path.home = real_home
    check(rc in (0, None) and state(held2_id).get("decision") == "revise", f"revise: recorded (rc={rc})")
    eml = tmp / "home" / "icsac-submissions" / "test" / "_outbox" / f"{held2_id}.eml"
    check(eml.exists(), "revise: the email is written (T2 outbox)")
    if eml.exists():
        import email as _email
        import email.policy as _policy
        msg = _email.message_from_bytes(eml.read_bytes(), policy=_policy.default)
        text = "".join(p.get_payload(decode=True).decode("utf-8", "replace")
                       for p in msg.walk() if p.get_content_type() == "text/plain")
        norm = " ".join(text.split())
        check("revise and resubmit" in str(msg["Subject"] or "").lower(), f"revise email: subject ({msg['Subject']})")
        check("accompanying archive" in norm, "revise email: quotes the paper's own sentence")
        check("Harvard Dataverse" in norm and "hosted by the author" in norm, "revise email: says where and how it shows")
        check("{{" not in text and "}}" not in text, "revise email: no unfilled fields")
        check("hasn't been sent to the reviewer panel" in norm, "revise email: says the panel did not run")
        check(not any(p.get_content_type() == "application/pdf" for p in msg.walk()), "revise email: no report attached")
        check("reply" not in norm.lower(), "revise email: invites no reply")

    # ── 5. Zenodo relation and the registry field ────────────────────────────
    print("5. deposit + registry")
    import repository_deposit as rd
    sub = {"title": "T", "abstract": "A", "keywords": ["k"], "license": "cc-by-4.0",
           "publication_date": "2026-09-28", "resource_type": "preprint",
           "creators": [{"name": "Author, Test"}], "related_identifiers": [],
           "code_data": {"available": True, "url": "https://github.com/example/generator"}}
    md = rd._build_metadata(sub)
    check({"identifier": "https://github.com/example/generator", "relation": "isSupplementedBy"}
          in md.get("related_identifiers", []), "Zenodo: the link is 'is supplemented by'")
    sub["related_identifiers"] = [{"identifier": "https://github.com/example/generator", "relation": "isSupplementedBy"}]
    md = rd._build_metadata(sub)
    check(len(md["related_identifiers"]) == 1, "Zenodo: no duplicate when the author also listed it")
    import publications as pub
    site = tmp / "site"; (site / "src" / "data").mkdir(parents=True)
    (site / "src" / "data" / "accepted.json").write_text("[]")
    pub.WEBSITE_REPO = str(site); pub.REGISTRY_PATH = str(site / "src" / "data" / "accepted.json")
    check(pub.code_data_url(sub) == "https://github.com/example/generator", "registry: helper reads the form link")
    e = pub.upsert_entry(approved=True, proto={"title": "T", "authors": ["A"], "doi": "10.67697/icsac.2026.999",
                          "source": "submission-pdf", "code_data_url": pub.code_data_url(sub)})
    check(e.get("code_data_url") == "https://github.com/example/generator", "registry: the entry keeps code_data_url")
    check(pub.code_data_url({"code_data": {"available": False, "url": None}}) is None, "registry: 'no' gives no link")
finally:
    pathlib.Path.home = real_home
    shutil.rmtree(tmp, ignore_errors=True)

print(f"\n{'PASS' if not failures else 'FAILED'}: {len(failures)} failure(s)")
sys.exit(0 if not failures else 1)