"""CAP-038 PAT revocation SLO test (REQ-351). Issue a PAT → vend a token (succeeds) → revoke the PAT → vend a token (403, reason ``pat_revoked``). Asserts the denial happens immediately (D-229: the strong read on the main table is synchronous — the 60s SLO is for propagation, which with strong reads is instant; assert <1s locally). Uses moto for DynamoDB. """ from __future__ import annotations import importlib.util import json import os import sys import time from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") os.environ.setdefault("AWS_ACCESS_KEY_ID", "test") os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test") os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1") _TV_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py" _spec = importlib.util.spec_from_file_location("nova_idp_token_vend_rev", _TV_PATH) tv = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(tv) import boto3 from moto import mock_aws from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes, serialization import core.kms_signing as kms_signing import core.pat_lifecycle as pat_life class _MockKms: def __init__(self, priv, pub_der): self._priv = priv self._pub_der = pub_der def sign(self, KeyId, Message, MessageType, SigningAlgorithm): return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))} def get_public_key(self, KeyId): return {"PublicKey": self._pub_der} def _create_pats_table(ddb): ddb.create_table( TableName="nova-pats", KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}], AttributeDefinitions=[{"AttributeName": "jti", "AttributeType": "S"}], BillingMode="PAY_PER_REQUEST", ) @pytest.fixture(autouse=True) def _reset(): tv._dynamodb = None pat_life._dynamodb = None yield tv._dynamodb = None pat_life._dynamodb = None kms_signing.set_kms_client_for_testing(None) @mock_aws def test_pat_revocation_slo(): """Issue → vend (ok) → revoke → vend (403 pat_revoked) in <1s (REQ-351).""" priv = ec.generate_private_key(ec.SECP256R1()) pub_der = priv.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) # 1. Issue a PAT. pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) # 2. Vend a token — succeeds (PAT active + ABAC allow developer+dev). body = {"token": pat, "environment": "dev"} resp1 = tv.lambda_handler({"body": json.dumps(body)}, None) assert resp1["statusCode"] == 200, resp1["body"] assert "token" in json.loads(resp1["body"]) # 3. Revoke the PAT. import base64 payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "==")) jti = payload["jti"] t0 = time.monotonic() pat_life.revoke_pat(jti) # 4. Vend again — 403 pat_revoked, immediately (<1s SLO, D-229 strong read). resp2 = tv.lambda_handler({"body": json.dumps(body)}, None) elapsed = time.monotonic() - t0 assert resp2["statusCode"] == 403 assert json.loads(resp2["body"])["reason"] == "pat_revoked" assert elapsed < 1.0, f"revocation took {elapsed:.3f}s — expected <1s (D-229 strong read)" @mock_aws def test_pat_revocation_then_abac_still_denies(): """After revocation, the denial reason is pat_revoked (not abac).""" priv = ec.generate_private_key(ec.SECP256R1()) pub_der = priv.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) import base64 payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "==")) pat_life.revoke_pat(payload["jti"]) body = {"token": pat, "environment": "dev"} resp = tv.lambda_handler({"body": json.dumps(body)}, None) assert resp["statusCode"] == 403 assert json.loads(resp["body"])["reason"] == "pat_revoked"