"""Unit tests for the auth crypto utilities — argon2 + JWT. Crypto code is small but high-stakes. Tests assert: - argon2 hashes are not reversible to plaintext - verify() returns False on tampered hash / wrong password - rehashing on parameter upgrade is detected - JWT issuance + decode round-trip - JWT signature tamper detection - JWT expiry enforcement - JWT issued for slot_id matches the regex """ from __future__ import annotations import time import pytest def test_argon2_hash_is_not_reversible(): from app.auth.passwords import hash_password, verify_password h = hash_password("correct horse battery staple") assert h != "correct horse battery staple" assert h.startswith("$argon2"), h def test_argon2_verify_correct_password(): from app.auth.passwords import hash_password, verify_password h = hash_password("hunter2!") assert verify_password(h, "hunter2!") is True def test_argon2_verify_wrong_password(): from app.auth.passwords import hash_password, verify_password h = hash_password("hunter2!") assert verify_password(h, "hunter3!") is False assert verify_password(h, "") is False assert verify_password(h, "hunter2! ") is False # trailing space matters def test_argon2_verify_tampered_hash(): from app.auth.passwords import hash_password, verify_password h = hash_password("hunter2!") tampered = h[:-3] + "AAA" assert verify_password(tampered, "hunter2!") is False def test_argon2_rejects_empty_or_huge_password(): from app.auth.passwords import hash_password, PasswordRejected with pytest.raises(PasswordRejected): hash_password("") # Argon2 rejects very long strings to avoid DoS via long inputs. with pytest.raises(PasswordRejected): hash_password("x" * 4096) def test_jwt_roundtrip(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_access_token, issue_access_token token = issue_access_token(client_id="11111111-1111-1111-1111-111111111111", slot_id="user_slot_001") claims = decode_access_token(token) assert claims["sub"] == "11111111-1111-1111-1111-111111111111" assert claims["aid"] == "user_slot_001" assert claims["typ"] == "access" def test_jwt_signature_tamper(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_access_token, issue_access_token, JWTInvalid token = issue_access_token(client_id="u", slot_id="user_slot_001") # Flip the last byte of the signature segment. parts = token.split(".") parts[2] = parts[2][:-2] + ("AA" if parts[2][-2:] != "AA" else "BB") tampered = ".".join(parts) with pytest.raises(JWTInvalid): decode_access_token(tampered) def test_jwt_wrong_secret(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_access_token, issue_access_token, JWTInvalid token = issue_access_token(client_id="u", slot_id="user_slot_001") monkeypatch.setenv("JWT_SECRET", "y" * 32) with pytest.raises(JWTInvalid): decode_access_token(token) def test_jwt_expired(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) monkeypatch.setenv("ACCESS_TOKEN_TTL_SECONDS", "1") from app.auth.tokens import decode_access_token, issue_access_token, JWTInvalid token = issue_access_token(client_id="u", slot_id="user_slot_001") time.sleep(1.2) with pytest.raises(JWTInvalid): decode_access_token(token) def test_jwt_rejects_invalid_slot_id(monkeypatch): """Per multi-tenant-auth §5 JWT shape, aid must match user_slot_.""" monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_access_token, JWTInvalid, issue_access_token # Forge a token with an aid that violates the regex. token = issue_access_token(client_id="u", slot_id="user_slot_001") # Decode legit first — should pass. decode_access_token(token) # Now build a manual token with bad aid and check decode rejects. import jwt import os bad = jwt.encode( {"sub": "u", "aid": "../etc/passwd", "typ": "access", "iat": int(time.time()), "exp": int(time.time()) + 60}, os.environ["JWT_SECRET"], algorithm="HS256", ) with pytest.raises(JWTInvalid): decode_access_token(bad) def test_refresh_token_roundtrip(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_refresh_token, issue_refresh_token token = issue_refresh_token(client_id="u") claims = decode_refresh_token(token) assert claims["sub"] == "u" assert claims["typ"] == "refresh" def test_refresh_token_rejected_as_access(monkeypatch): monkeypatch.setenv("JWT_SECRET", "x" * 32) from app.auth.tokens import decode_access_token, issue_refresh_token, JWTInvalid rtoken = issue_refresh_token(client_id="u") with pytest.raises(JWTInvalid): decode_access_token(rtoken)