14809327fb
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: cli-engineer ---
207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
"""nova auth login/revoke/status tests (REQ-344, C-7.3).
|
|
|
|
C-7.3: ``~/.nova/credentials.json`` stores OIDC token + PAT metadata
|
|
(jti, exp, type) ONLY — NOT the raw PAT. Verified by asserting the
|
|
file contains no ``raw_pat`` / ``pat`` field.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
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")
|
|
|
|
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
|
|
import core.auth_store as auth_store
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test keypair + mock KMS.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_keypair():
|
|
priv = ec.generate_private_key(ec.SECP256R1())
|
|
pub = priv.public_key()
|
|
pub_der = pub.public_bytes(
|
|
encoding=serialization.Encoding.DER,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
)
|
|
return priv, pub, pub_der
|
|
|
|
|
|
def _create_pats_table(ddb):
|
|
ddb.create_table(
|
|
TableName="nova-pats",
|
|
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
|
AttributeDefinitions=[
|
|
{"AttributeName": "jti", "AttributeType": "S"},
|
|
{"AttributeName": "sub", "AttributeType": "S"},
|
|
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
|
],
|
|
GlobalSecondaryIndexes=[
|
|
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
|
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
|
],
|
|
BillingMode="PAY_PER_REQUEST",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cred_file(tmp_path, monkeypatch):
|
|
"""Isolate credentials.json to a tmp path."""
|
|
cred = tmp_path / "credentials.json"
|
|
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(cred))
|
|
yield cred
|
|
kms_signing.set_kms_client_for_testing(None)
|
|
pat_life._dynamodb = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# nova auth login
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mock_aws
|
|
def test_login_stores_oidc_token_not_raw_pat(tmp_path, test_keypair, _cred_file):
|
|
priv, _pub, pub_der = test_keypair
|
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
|
_create_pats_table(ddb)
|
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
|
# Issue a PAT.
|
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
|
assert pat # raw PAT returned once
|
|
# Run nova auth login via the local Lambda path.
|
|
from nova.auth import login as login_mod
|
|
args = mock.MagicMock()
|
|
args.pat = pat
|
|
args.session = None
|
|
args.environment = "dev"
|
|
args.endpoint = "" # local
|
|
rc = login_mod.run(args)
|
|
assert rc == 0
|
|
# Assert credentials.json exists + is 0600.
|
|
assert _cred_file.exists()
|
|
mode = stat.S_IMODE(os.stat(_cred_file).st_mode)
|
|
assert mode == 0o600
|
|
data = json.loads(_cred_file.read_text())
|
|
# C-7.3: contains the OIDC token + metadata, NOT the raw PAT.
|
|
cred = data["credentials"][0]
|
|
assert "token" in cred # the OIDC token
|
|
assert cred["type"] == "nova_oidc_token"
|
|
assert "jti" in cred and "exp" in cred
|
|
raw = _cred_file.read_text()
|
|
assert "raw_pat" not in raw
|
|
assert pat not in raw # the raw PAT string must NOT appear
|
|
|
|
|
|
@mock_aws
|
|
def test_login_denied_pat_returns_error(tmp_path, test_keypair, _cred_file):
|
|
priv, _pub, pub_der = test_keypair
|
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
|
_create_pats_table(ddb)
|
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
|
# Revoke it.
|
|
# Extract jti from the PAT.
|
|
import base64
|
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
|
pat_life.revoke_pat(payload["jti"])
|
|
from nova.auth import login as login_mod
|
|
args = mock.MagicMock()
|
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
|
rc = login_mod.run(args)
|
|
assert rc != 0 # denied
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# nova auth status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_status_no_credential(_cred_file, capsys):
|
|
from nova.auth import status as status_mod
|
|
rc = status_mod.run(mock.MagicMock())
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "no active credential" in out
|
|
|
|
|
|
@mock_aws
|
|
def test_status_shows_mode_and_jti(tmp_path, test_keypair, _cred_file, capsys):
|
|
priv, _pub, pub_der = test_keypair
|
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
|
_create_pats_table(ddb)
|
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
|
from nova.auth import login as login_mod
|
|
args = mock.MagicMock()
|
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
|
login_mod.run(args)
|
|
capsys.readouterr() # drain login output
|
|
from nova.auth import status as status_mod
|
|
rc = status_mod.run(mock.MagicMock())
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
data = json.loads(out)
|
|
assert "mode" in data
|
|
assert "selection_reason" in data
|
|
assert data["type"] == "nova_oidc_token"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# nova auth revoke
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mock_aws
|
|
def test_revoke_sets_status_revoked(tmp_path, test_keypair, _cred_file, capsys):
|
|
priv, _pub, pub_der = test_keypair
|
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
|
_create_pats_table(ddb)
|
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
|
import base64
|
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
|
jti = payload["jti"]
|
|
from nova.auth import revoke as revoke_mod
|
|
args = mock.MagicMock()
|
|
args.pat = jti; args.endpoint = ""
|
|
rc = revoke_mod.run(args)
|
|
assert rc == 0
|
|
# Verify status=revoked in DDB.
|
|
item = ddb.get_item(TableName="nova-pats", Key={"jti": {"S": jti}}, ConsistentRead=True)
|
|
assert item["Item"]["status"]["S"] == "revoked" |