Skip to content

Commit 6346a53

Browse files
committed
feat: add multi-author tokens
1 parent 34d27f3 commit 6346a53

4 files changed

Lines changed: 197 additions & 13 deletions

File tree

scripts/test_contributions_integration.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,114 @@ def check(response, expected_status):
523523
v6 = check(r, 200)
524524
print(f" commit v6: {v6['commit'][:12]}")
525525

526+
# ---------------------------------------------------------------------------
527+
# Step 19: Create multi_author token capped at 7 days → 200
528+
# ---------------------------------------------------------------------------
529+
530+
sep("Step 19: GET multi_author token (expect 200, expires_days <= 7)")
531+
r = requests.get(
532+
TOKEN_URL,
533+
params={
534+
"doi": DOI,
535+
"type": "multi_author",
536+
"days": "9999",
537+
"password": PASSWORD,
538+
},
539+
)
540+
ma_data = check(r, 200)
541+
assert ma_data["type"] == "multi_author"
542+
assert "token" in ma_data
543+
assert ma_data["expires_days"] <= 7, f"expected expires_days <= 7, got {ma_data['expires_days']}"
544+
multi_author_token = ma_data["token"]
545+
print(f" token: {multi_author_token[:12]}...")
546+
print(f" expires_days: {ma_data['expires_days']}")
547+
548+
# ---------------------------------------------------------------------------
549+
# Step 20: First person uses multi_author token to add Evan Torres
550+
# ---------------------------------------------------------------------------
551+
552+
V7 = {
553+
"project_name": PROJECT,
554+
"doi": DOI,
555+
"contributors": V5["contributors"] + [
556+
{
557+
"author": {
558+
"name": "Evan Torres",
559+
"registry": "Open Researcher and Contributor ID (ORCID)",
560+
"registry_identifier": "0000-0005-6789-0123",
561+
"affiliation": ["Allen Institute for Neural Dynamics"],
562+
},
563+
"credit_levels": [{"role": "investigation", "level": "equal"}],
564+
}
565+
],
566+
"sections": ["Introduction", "Methods", "Results"],
567+
}
568+
569+
sep("Step 20: POST using multi_author token (first use — add Evan, expect 200)")
570+
r = requests.post(
571+
f"{POST_URL}?project={PROJECT}&message=add+evan+via+multi+token&password={multi_author_token}",
572+
data=json.dumps(V7).encode("utf-8"),
573+
headers={"Content-Type": "application/json"},
574+
)
575+
v7 = check(r, 200)
576+
print(f" commit v7: {v7['commit'][:12]}")
577+
578+
# ---------------------------------------------------------------------------
579+
# Step 21: Second person uses the same token to add Fatima (reusable)
580+
# ---------------------------------------------------------------------------
581+
582+
V8 = {
583+
"project_name": PROJECT,
584+
"doi": DOI,
585+
"contributors": V7["contributors"] + [
586+
{
587+
"author": {
588+
"name": "Fatima Hassan",
589+
"registry": "Open Researcher and Contributor ID (ORCID)",
590+
"registry_identifier": "0000-0006-7890-1234",
591+
"affiliation": ["Allen Institute for Neural Dynamics"],
592+
},
593+
"credit_levels": [{"role": "validation", "level": "equal"}],
594+
}
595+
],
596+
"sections": ["Introduction", "Methods", "Results"],
597+
}
598+
599+
sep("Step 21: POST using same multi_author token (second use — add Fatima, expect 200)")
600+
r = requests.post(
601+
f"{POST_URL}?project={PROJECT}&message=add+fatima+via+multi+token&password={multi_author_token}",
602+
data=json.dumps(V8).encode("utf-8"),
603+
headers={"Content-Type": "application/json"},
604+
)
605+
v8 = check(r, 200)
606+
print(f" commit v8: {v8['commit'][:12]}")
607+
608+
# ---------------------------------------------------------------------------
609+
# Step 22: GET — verify Evan and Fatima both added
610+
# ---------------------------------------------------------------------------
611+
612+
sep("Step 22: GET (verify Evan and Fatima present)")
613+
r = requests.get(GET_URL, params={"project": PROJECT})
614+
data = check(r, 200)
615+
names = [c["author"]["name"] for c in data["contributors"]]
616+
assert "Evan Torres" in names, f"Evan Torres not found in {names}"
617+
assert "Fatima Hassan" in names, f"Fatima Hassan not found in {names}"
618+
print(f" contributors: {names}")
619+
620+
# ---------------------------------------------------------------------------
621+
# Step 23: multi_author token cannot remove an existing author → 403
622+
# ---------------------------------------------------------------------------
623+
624+
V8_remove = {**V8, "contributors": [c for c in V8["contributors"] if c["author"]["name"] != "Alice Nguyen"]}
625+
626+
sep("Step 23: POST with multi_author token removing an author (expect 403)")
627+
r = requests.post(
628+
f"{POST_URL}?project={PROJECT}&message=remove+alice+via+multi+token&password={multi_author_token}",
629+
data=json.dumps(V8_remove).encode("utf-8"),
630+
headers={"Content-Type": "application/json"},
631+
)
632+
check(r, 403)
633+
526634
print("\n" + "=" * 60)
527635
print(" ALL STEPS PASSED")
528636
print("=" * 60)

src/aind_metadata_viz/contributions/handlers.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
lookup_token,
3737
set_project_password,
3838
verify_project_password,
39+
_MAX_MULTI_AUTHOR_DAYS,
3940
)
4041

4142

@@ -47,6 +48,8 @@ def _validate_token_scope(project_name, token_type, author_name, new_contributio
4748
4849
* ``"add_author"`` — all existing authors must remain unchanged; exactly
4950
one new author may be added.
51+
* ``"multi_author"`` — same add-one-author rule as ``add_author``, but the
52+
token is reusable so multiple people may each add themselves.
5053
* ``"edit_author"`` — only the author identified by *author_name* may
5154
be modified; no authors may be added or removed.
5255
"""
@@ -58,23 +61,23 @@ def _validate_token_scope(project_name, token_type, author_name, new_contributio
5861
existing_names = {c.author.name for c in existing.contributors} if existing else set()
5962
new_names = {c.author.name for c in new_contributions.contributors}
6063

61-
if token_type == "add_author":
64+
if token_type in ("add_author", "multi_author"):
6265
removed = existing_names - new_names
6366
if removed:
6467
return False, (
65-
"add_author token cannot remove existing authors: "
68+
f"{token_type} token cannot remove existing authors: "
6669
+ ", ".join(sorted(removed))
6770
)
6871
added = new_names - existing_names
6972
if len(added) != 1:
70-
return False, "add_author token allows adding exactly one new author"
73+
return False, f"{token_type} token allows adding exactly one new author"
7174
if existing:
7275
existing_by_name = {c.author.name: c for c in existing.contributors}
7376
for c in new_contributions.contributors:
7477
if c.author.name in existing_by_name:
7578
if c.model_dump_json() != existing_by_name[c.author.name].model_dump_json():
7679
return False, (
77-
f"add_author token cannot modify existing author '{c.author.name}'"
80+
f"{token_type} token cannot modify existing author '{c.author.name}'"
7881
)
7982
return True, None
8083

@@ -319,9 +322,9 @@ def get(self):
319322
return
320323

321324
token_type = self.get_argument("type", None)
322-
if token_type not in ("add_author", "edit_author"):
325+
if token_type not in ("add_author", "edit_author", "multi_author"):
323326
self.set_status(400)
324-
self.write(json.dumps({"error": "type must be 'add_author' or 'edit_author'"}))
327+
self.write(json.dumps({"error": "type must be 'add_author', 'edit_author', or 'multi_author'"}))
325328
return
326329

327330
author = self.get_argument("author", None)
@@ -362,7 +365,7 @@ def get(self):
362365
self.write(json.dumps({"error": str(e)}))
363366
return
364367

365-
capped_days = min(days, 365)
368+
capped_days = min(days, _MAX_MULTI_AUTHOR_DAYS if token_type == "multi_author" else 365)
366369
self.set_status(200)
367370
self.write(json.dumps({"token": token_id, "type": token_type, "expires_days": capped_days}))
368371

src/aind_metadata_viz/contributions/store.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ def get_contributions_by_doi(
248248

249249

250250
_MAX_TOKEN_DAYS = 365
251+
_MAX_MULTI_AUTHOR_DAYS = 7
251252

252253

253254
def _token_key(project_name: str) -> str:
@@ -268,26 +269,30 @@ def create_token(
268269
project_name:
269270
Name of the target project.
270271
token_type:
271-
``"add_author"`` (one-time use) or ``"edit_author"`` (scoped to
272-
*author_name*, reusable until expiry).
272+
``"add_author"`` (one-time use), ``"edit_author"`` (scoped to
273+
*author_name*, reusable until expiry), or ``"multi_author"``
274+
(reusable, lets multiple people each add themselves, capped at 7 days).
273275
author_name:
274276
Required for ``"edit_author"`` tokens.
275277
expires_days:
276-
Days until expiry from now; capped at ``_MAX_TOKEN_DAYS`` (365).
278+
Days until expiry from now; capped at ``_MAX_TOKEN_DAYS`` (365) for
279+
``add_author``/``edit_author``, or ``_MAX_MULTI_AUTHOR_DAYS`` (7) for
280+
``multi_author``.
277281
278282
Returns
279283
-------
280284
str
281285
The UUID token the recipient presents in place of a password.
282286
"""
283-
if token_type not in ("add_author", "edit_author"):
287+
if token_type not in ("add_author", "edit_author", "multi_author"):
284288
raise ValueError(
285-
f"token_type must be 'add_author' or 'edit_author', got {token_type!r}"
289+
f"token_type must be 'add_author', 'edit_author', or 'multi_author', got {token_type!r}"
286290
)
287291
if token_type == "edit_author" and not author_name:
288292
raise ValueError("author_name is required for edit_author tokens")
289293

290-
days = min(max(1, expires_days), _MAX_TOKEN_DAYS)
294+
max_days = _MAX_MULTI_AUTHOR_DAYS if token_type == "multi_author" else _MAX_TOKEN_DAYS
295+
days = min(max(1, expires_days), max_days)
291296
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
292297

293298
token_id = uuid.uuid4().hex

tests/test_contributions.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,30 @@ def test_edit_author_without_name_raises(self):
932932
with self.assertRaises(ValueError):
933933
create_token("tok-project", "edit_author")
934934

935+
def test_create_multi_author_token(self):
936+
token = create_token("tok-project", "multi_author")
937+
record = lookup_token("tok-project", token)
938+
self.assertIsNotNone(record)
939+
self.assertEqual(record["token_type"], "multi_author")
940+
self.assertIsNone(record["author_name"])
941+
942+
def test_multi_author_expires_capped_at_7_days(self):
943+
from datetime import datetime, timezone
944+
token = create_token("tok-project", "multi_author", expires_days=9999)
945+
record = lookup_token("tok-project", token)
946+
expires = datetime.fromisoformat(record["expires_at"])
947+
delta = expires - datetime.now(timezone.utc)
948+
self.assertLessEqual(delta.days, 7)
949+
950+
def test_multi_author_expires_custom_within_7_days(self):
951+
from datetime import datetime, timezone
952+
token = create_token("tok-project", "multi_author", expires_days=3)
953+
record = lookup_token("tok-project", token)
954+
expires = datetime.fromisoformat(record["expires_at"])
955+
delta = expires - datetime.now(timezone.utc)
956+
self.assertLessEqual(delta.days, 3)
957+
self.assertGreater(delta.days, 1)
958+
935959
def test_lookup_unknown_token_returns_none(self):
936960
self.assertIsNone(lookup_token("tok-project", "notavalidtoken"))
937961

@@ -1155,6 +1179,34 @@ def test_invalid_type_returns_400(self):
11551179
resp = self.fetch("/contributions/token?doi=10.1/x&type=bad_type")
11561180
self.assertEqual(resp.code, 400)
11571181

1182+
def test_multi_author_token_created(self):
1183+
pc = _make_project("tok-project")
1184+
with self._patch_contributions_by_doi(pc), self._patch_verify(True), \
1185+
self._patch_create_token("ccddee11" * 4):
1186+
resp = self.fetch("/contributions/token?doi=10.1/x&type=multi_author")
1187+
self.assertEqual(resp.code, 200)
1188+
body = json.loads(resp.body)
1189+
self.assertEqual(body["type"], "multi_author")
1190+
self.assertEqual(body["token"], "ccddee11" * 4)
1191+
1192+
def test_multi_author_expires_days_capped_at_7(self):
1193+
pc = _make_project("tok-project")
1194+
with self._patch_contributions_by_doi(pc), self._patch_verify(True), \
1195+
self._patch_create_token():
1196+
resp = self.fetch("/contributions/token?doi=10.1/x&type=multi_author&days=9999")
1197+
self.assertEqual(resp.code, 200)
1198+
body = json.loads(resp.body)
1199+
self.assertEqual(body["expires_days"], 7)
1200+
1201+
def test_multi_author_custom_days_within_cap(self):
1202+
pc = _make_project("tok-project")
1203+
with self._patch_contributions_by_doi(pc), self._patch_verify(True), \
1204+
self._patch_create_token():
1205+
resp = self.fetch("/contributions/token?doi=10.1/x&type=multi_author&days=3")
1206+
self.assertEqual(resp.code, 200)
1207+
body = json.loads(resp.body)
1208+
self.assertEqual(body["expires_days"], 3)
1209+
11581210
def test_edit_author_without_author_param_returns_400(self):
11591211
resp = self.fetch("/contributions/token?doi=10.1/x&type=edit_author")
11601212
self.assertEqual(resp.code, 400)
@@ -1339,6 +1391,22 @@ def test_valid_edit_author_token_not_consumed(self):
13391391
self.assertEqual(resp.code, 200)
13401392
mock_consume.assert_not_called()
13411393

1394+
def test_valid_multi_author_token_not_consumed(self):
1395+
record = {"token_id": "tok4", "token_type": "multi_author", "author_name": None}
1396+
body = to_json(self._make_project_with_two_authors())
1397+
mock_consume = MagicMock()
1398+
with self._patch_store(), self._patch_lookup_token(record), \
1399+
self._patch_validate_scope((True, None)), \
1400+
patch("aind_metadata_viz.contributions.handlers.consume_token", mock_consume):
1401+
resp = self.fetch(
1402+
"/contributions/post?project=tok-post-project&password=tok4",
1403+
method="POST",
1404+
body=body,
1405+
headers={"Content-Type": "application/json"},
1406+
)
1407+
self.assertEqual(resp.code, 200)
1408+
mock_consume.assert_not_called()
1409+
13421410
def test_out_of_scope_token_returns_403(self):
13431411
record = {"token_id": "tok1", "token_type": "add_author", "author_name": None}
13441412
body = to_json(self._make_project_with_two_authors())

0 commit comments

Comments
 (0)