09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Unit tests for the HMAC file-transfer token signer (``mcp/_filelink.py``).
|
|
|
|
The money path: a token must round-trip its payload, reject any tampering / wrong
|
|
operation / expiry, cap its length before decoding, and tolerate base64url with or
|
|
without padding. No fastmcp needed — stdlib only — but kept under ``tests/unit/mcp``
|
|
beside the other MCP tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hmac
|
|
import json
|
|
import time
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import notebooklm.mcp._filelink as filelink
|
|
from notebooklm.mcp._filelink import (
|
|
DOWNLOAD_TTL,
|
|
UPLOAD_TTL,
|
|
ConsumedJtiStore,
|
|
FileLinkError,
|
|
FileLinkSigner,
|
|
FileTransferConfig,
|
|
_b64url_decode,
|
|
)
|
|
|
|
KEY = b"k" * 32
|
|
|
|
|
|
def _signer() -> FileLinkSigner:
|
|
return FileLinkSigner(KEY)
|
|
|
|
|
|
def test_round_trip_returns_payload_with_injected_exp() -> None:
|
|
signer = _signer()
|
|
before = int(time.time())
|
|
token = signer.sign({"op": "ul", "nb": "n1", "title": "Doc"}, ttl=60)
|
|
payload = signer.verify(token, op="ul")
|
|
assert payload["op"] == "ul"
|
|
assert payload["nb"] == "n1"
|
|
assert payload["title"] == "Doc"
|
|
# The signer OWNS expiry: callers never pass exp; it is injected as now+ttl.
|
|
assert before + 60 <= payload["exp"] <= int(time.time()) + 61
|
|
|
|
|
|
def test_tampered_body_is_rejected() -> None:
|
|
signer = _signer()
|
|
token = signer.sign({"op": "ul", "nb": "n1"}, ttl=60)
|
|
body_b64, mac_b64 = token.split(".")
|
|
# Flip the notebook id in the (decoded) body, re-encode, keep the old MAC.
|
|
payload = json.loads(_b64url_decode(body_b64))
|
|
payload["nb"] = "attacker"
|
|
forged_body = (
|
|
base64.urlsafe_b64encode(
|
|
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
|
)
|
|
.rstrip(b"=")
|
|
.decode()
|
|
)
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(f"{forged_body}.{mac_b64}", op="ul")
|
|
|
|
|
|
def test_tampered_mac_is_rejected() -> None:
|
|
signer = _signer()
|
|
token = signer.sign({"op": "ul", "nb": "n1"}, ttl=60)
|
|
body_b64, _mac = token.split(".")
|
|
bad_mac = base64.urlsafe_b64encode(b"x" * 32).rstrip(b"=").decode()
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(f"{body_b64}.{bad_mac}", op="ul")
|
|
|
|
|
|
def test_wrong_key_is_rejected() -> None:
|
|
token = _signer().sign({"op": "dl", "nb": "n1", "atype": "audio"}, ttl=60)
|
|
with pytest.raises(FileLinkError):
|
|
FileLinkSigner(b"z" * 32).verify(token, op="dl")
|
|
|
|
|
|
def test_expired_token_is_rejected() -> None:
|
|
signer = _signer()
|
|
# A negative TTL injects an exp already in the past — no clock patching needed.
|
|
token = signer.sign({"op": "ul", "nb": "n1"}, ttl=-10)
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(token, op="ul")
|
|
|
|
|
|
def test_operation_mismatch_is_rejected() -> None:
|
|
signer = _signer()
|
|
upload_token = signer.sign({"op": "ul", "nb": "n1"}, ttl=60)
|
|
# A valid upload token must NOT verify against the download route's op.
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(upload_token, op="dl")
|
|
|
|
|
|
def test_over_length_token_rejected() -> None:
|
|
signer = _signer()
|
|
# An over-length token (> the 4 KiB cap) is rejected by the pre-decode length
|
|
# guard. Crucially it carries a single "." and valid base64url so the ONLY thing
|
|
# that can reject it is the length cap — if the cap didn't fire first, decode +
|
|
# MAC work would run; the rejection proves the cap short-circuits.
|
|
body = "A" * 5000
|
|
mac = base64.urlsafe_b64encode(b"x" * 32).rstrip(b"=").decode()
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(f"{body}.{mac}", op="ul")
|
|
# Sanity: the cap is what trips — a same-shaped but short token gets past length
|
|
# and fails later (still a FileLinkError, but for a different reason).
|
|
assert len(body) + len(mac) + 1 > filelink._MAX_TOKEN_LEN
|
|
|
|
|
|
def test_malformed_token_shapes_rejected() -> None:
|
|
signer = _signer()
|
|
for bad in ("", "no-dot", "a.b.c", ".", "a.", ".b"):
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(bad, op="ul")
|
|
|
|
|
|
def test_non_ascii_token_body_raises_filelinkerror_not_unicodeerror() -> None:
|
|
# A non-ASCII char in the body segment must surface as a FileLinkError (→ flat
|
|
# 403 at the route), NOT an uncaught UnicodeEncodeError (a bare 500). Security
|
|
# finding: malformed public input should reject cleanly.
|
|
signer = _signer()
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify("é.bm9wZQ", op="ul")
|
|
|
|
|
|
def test_base64url_padding_tolerant() -> None:
|
|
# The encoder strips '=' padding; the decoder must accept inputs needing 0..3
|
|
# pad bytes back. Cover lengths that re-pad to each residue.
|
|
for raw in (b"a", b"ab", b"abc", b"abcd"):
|
|
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
assert _b64url_decode(encoded) == raw
|
|
# And the fully-padded form decodes identically.
|
|
assert _b64url_decode(base64.urlsafe_b64encode(raw).decode()) == raw
|
|
|
|
|
|
def test_verify_uses_constant_time_compare() -> None:
|
|
signer = _signer()
|
|
token = signer.sign({"op": "ul", "nb": "n1"}, ttl=60)
|
|
# Object patch on the stdlib ``hmac`` (a public attr), not a string target.
|
|
with mock.patch.object(hmac, "compare_digest", wraps=hmac.compare_digest) as cmp:
|
|
signer.verify(token, op="ul")
|
|
cmp.assert_called_once()
|
|
|
|
|
|
def test_config_builds_ttl_scoped_urls() -> None:
|
|
signer = _signer()
|
|
config = FileTransferConfig(signer=signer, base_url="https://host.example/")
|
|
up = config.upload_url({"op": "ul", "nb": "n1"})
|
|
down = config.download_url({"op": "dl", "nb": "n1", "atype": "audio"})
|
|
assert up.startswith("https://host.example/files/ul/")
|
|
assert down.startswith("https://host.example/files/dl/")
|
|
# The trailing slash on base_url is not doubled.
|
|
assert "//files" not in up.replace("https://", "")
|
|
# Upload token carries the 15-min TTL, download the 30-min TTL.
|
|
up_exp = signer.verify(up.rsplit("/", 1)[1], op="ul")["exp"]
|
|
down_exp = signer.verify(down.rsplit("/", 1)[1], op="dl")["exp"]
|
|
assert UPLOAD_TTL == 15 * 60 and DOWNLOAD_TTL == 30 * 60
|
|
assert down_exp - up_exp >= DOWNLOAD_TTL - UPLOAD_TTL - 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# jti minting (single-use support — #1746)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_sign_injects_unique_jti_per_mint() -> None:
|
|
# Every token carries a random jti (CSPRNG), so two mints of the SAME payload get
|
|
# DIFFERENT jtis — the property the ul single-use tracker keys off.
|
|
signer = _signer()
|
|
p1 = signer.verify(signer.sign({"op": "ul", "nb": "n1"}, ttl=60), op="ul")
|
|
p2 = signer.verify(signer.sign({"op": "ul", "nb": "n1"}, ttl=60), op="ul")
|
|
assert isinstance(p1["jti"], str) and p1["jti"]
|
|
assert isinstance(p2["jti"], str) and p2["jti"]
|
|
assert p1["jti"] != p2["jti"]
|
|
|
|
|
|
def test_jti_is_covered_by_the_mac() -> None:
|
|
# The jti is injected into the signed body, so tampering with it (keeping the old
|
|
# MAC) is rejected — same guarantee as any other payload field.
|
|
signer = _signer()
|
|
token = signer.sign({"op": "ul", "nb": "n1"}, ttl=60)
|
|
body_b64, mac_b64 = token.split(".")
|
|
payload = json.loads(_b64url_decode(body_b64))
|
|
payload["jti"] = "attacker-chosen-jti"
|
|
forged_body = (
|
|
base64.urlsafe_b64encode(
|
|
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
|
)
|
|
.rstrip(b"=")
|
|
.decode()
|
|
)
|
|
with pytest.raises(FileLinkError):
|
|
signer.verify(f"{forged_body}.{mac_b64}", op="ul")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ConsumedJtiStore lifecycle
|
|
# --------------------------------------------------------------------------- #
|
|
def test_store_try_begin_then_commit_makes_jti_single_use() -> None:
|
|
store = ConsumedJtiStore()
|
|
exp = int(time.time()) + 60
|
|
assert store.try_begin("j1") is True # first claim wins
|
|
assert store.try_begin("j1") is False # concurrent duplicate rejected (still active)
|
|
store.commit("j1", exp)
|
|
assert store.try_begin("j1") is False # consumed → permanently rejected
|
|
|
|
|
|
def test_store_rollback_frees_jti_for_retry() -> None:
|
|
# A claimed-but-not-committed jti (failed/aborted upload) is released, so the same
|
|
# link can be retried — the record-on-success behavior ADR-0024 relies on.
|
|
store = ConsumedJtiStore()
|
|
assert store.try_begin("j1") is True
|
|
store.rollback("j1")
|
|
assert store.try_begin("j1") is True # reusable after rollback
|
|
|
|
|
|
def test_store_sweeps_expired_seen_entries_on_commit() -> None:
|
|
# Expired jtis are inline-swept (memory reclamation) when a later commit runs; a
|
|
# live one is retained. A swept jti being re-claimable is harmless — verify()
|
|
# rejects the expired token itself on the exp check.
|
|
store = ConsumedJtiStore()
|
|
now = int(time.time())
|
|
store.commit("old", now - 10) # already expired
|
|
store.commit("fresh", now + 60) # live
|
|
assert "old" not in store._seen
|
|
assert "fresh" in store._seen
|
|
assert store.try_begin("old") is True # swept → re-claimable (harmless)
|
|
|
|
|
|
def test_store_bound_evicts_soonest_to_expire(monkeypatch) -> None:
|
|
monkeypatch.setattr(filelink, "_MAX_SEEN_JTIS", 3)
|
|
store = ConsumedJtiStore()
|
|
base = int(time.time()) + 10_000 # all far-future so the sweep never fires
|
|
store.commit("a", base + 1)
|
|
store.commit("b", base + 5)
|
|
store.commit("c", base + 9)
|
|
store.commit("d", base + 7) # over cap → evict the soonest-to-expire ("a")
|
|
assert len(store._seen) <= 3
|
|
assert "a" not in store._seen # soonest-to-expire evicted
|
|
assert {"b", "c", "d"} <= set(store._seen)
|
|
|
|
|
|
def test_store_recommit_same_jti_does_not_evict_at_cap(monkeypatch) -> None:
|
|
# Re-committing an already-recorded jti refreshes its exp in place; it must NOT evict
|
|
# a different valid entry (order-independence at the size cap). The route can't drive
|
|
# a double-commit — try_begin gates it — but commit stays self-consistent regardless.
|
|
monkeypatch.setattr(filelink, "_MAX_SEEN_JTIS", 2)
|
|
store = ConsumedJtiStore()
|
|
base = int(time.time()) + 10_000 # far future → sweep never fires
|
|
store.commit("a", base + 1)
|
|
store.commit("b", base + 5) # store is now full (2/2)
|
|
store.commit("a", base + 9) # re-commit "a" — must refresh, not evict "b"
|
|
assert set(store._seen) == {"a", "b"}
|
|
assert store._seen["a"] == base + 9 # exp refreshed in place
|
|
|
|
|
|
def test_config_jti_store_excluded_from_equality_and_default_constructed() -> None:
|
|
# `compare=False` keeps the frozen config comparable by (signer, base_url) only —
|
|
# the store is a mutable, dict-bearing object that must not drive __eq__/__hash__.
|
|
signer = _signer()
|
|
a = FileTransferConfig(signer=signer, base_url="https://h.example")
|
|
b = FileTransferConfig(signer=signer, base_url="https://h.example")
|
|
assert isinstance(a.jti_store, ConsumedJtiStore) # default-constructed
|
|
a.jti_store.try_begin("j1") # mutate one store
|
|
assert a == b # equality ignores the (now diverged) stores
|