feat(P04): nova idp setup --check/--apply/--verify (REQ-340/341, C-2.1, backend+cli)
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: backend-engineer ---
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""CloudFormation template for the Nova IdP (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Composes the DynamoDB snippet (from P3 ``nova_idp_auth_cfn.py``) + 3
|
||||
Lambdas (``nova-idp-auth``, ``nova-idp-token-vend``, ``nova-idp-jwks``)
|
||||
+ KMS key (``alias/nova-oidc-signing``, ``ECC_NIST_P256``,
|
||||
``SIGN_VERIFY``) + function URLs + IAM roles + optional
|
||||
CloudFront/WAF/ACM (when ``public_jwks_domain`` is provided).
|
||||
|
||||
:func:`generate_template` returns a CloudFormation template dict (no
|
||||
troposphere dependency — raw dict → JSON).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _load_auth_cfn():
|
||||
"""Load core/lambda/nova_idp_auth_cfn.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__file__).parent / "nova_idp_auth_cfn.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_auth_cfn", p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_auth_cfn = _load_auth_cfn()
|
||||
dynamodb_tables_snippet = _auth_cfn.dynamodb_tables_snippet
|
||||
table_names = _auth_cfn.table_names
|
||||
|
||||
|
||||
def _lambda_role(logical_id: str, table_envs: dict[str, str], kms: bool = False) -> dict:
|
||||
"""Build an IAM role for a Nova IdP Lambda."""
|
||||
statements = [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
||||
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"},
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["logs:CreateLogGroup"],
|
||||
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"},
|
||||
},
|
||||
]
|
||||
if table_envs:
|
||||
statements.append({
|
||||
"Effect": "Allow",
|
||||
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
|
||||
"dynamodb:Query", "dynamodb:DeleteItem"],
|
||||
"Resource": [
|
||||
{"Fn::Sub": f"arn:aws:dynamodb:${{AWS::Region}}:${{AWS::AccountId}}:table/{name}"}
|
||||
for name in table_envs.values()
|
||||
],
|
||||
})
|
||||
if kms:
|
||||
statements.append({
|
||||
"Effect": "Allow",
|
||||
"Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
||||
"Resource": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||
})
|
||||
return {
|
||||
"Type": "AWS::IAM::Role",
|
||||
"Properties": {
|
||||
"AssumeRolePolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": {"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"}},
|
||||
"Action": "sts:AssumeRole",
|
||||
}],
|
||||
},
|
||||
"Policies": [{"PolicyName": f"{logical_id}Policy", "PolicyDocument": {
|
||||
"Version": "2012-10-17", "Statement": statements,
|
||||
}}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _lambda_function(logical_id: str, handler: str, role_ref: str,
|
||||
env_vars: dict[str, str], memory: int = 512) -> dict:
|
||||
return {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": handler,
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": memory,
|
||||
"Timeout": 30,
|
||||
"Role": {"Fn::GetAtt": [role_ref, "Arn"]},
|
||||
"Environment": {"Variables": env_vars},
|
||||
"Code": {"ZipFile": "def lambda_handler(event, context):\n return {}"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _function_url(logical_id: str, auth_type: str = "AWS_IAM") -> dict:
|
||||
return {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {"Ref": logical_id},
|
||||
"AuthType": auth_type,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_template(public_jwks_domain: str | None = None) -> Dict[str, Any]:
|
||||
"""Generate the full Nova IdP CloudFormation template (REQ-340).
|
||||
|
||||
Args:
|
||||
public_jwks_domain: optional custom domain for the JWKS endpoint.
|
||||
When provided, CloudFront + ACM + WAF resources are added.
|
||||
|
||||
Returns:
|
||||
A CloudFormation template dict (``{"Resources": {...}}``).
|
||||
"""
|
||||
resources: Dict[str, Any] = {}
|
||||
# DynamoDB tables (from P3).
|
||||
resources.update(dynamodb_tables_snippet())
|
||||
names = table_names()
|
||||
|
||||
# KMS key (ECC_NIST_P256, SIGN_VERIFY) + alias.
|
||||
resources["NovaOidcSigningKey"] = {
|
||||
"Type": "AWS::KMS::Key",
|
||||
"Properties": {
|
||||
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
|
||||
"KeySpec": "ECC_NIST_P256",
|
||||
"KeyUsage": "SIGN_VERIFY",
|
||||
"KeyPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": {"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"}},
|
||||
"Action": "kms:*",
|
||||
"Resource": "*",
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
resources["NovaOidcSigningKeyAlias"] = {
|
||||
"Type": "AWS::KMS::Alias",
|
||||
"Properties": {
|
||||
"AliasName": "alias/nova-oidc-signing",
|
||||
"TargetKeyId": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||
},
|
||||
}
|
||||
|
||||
# Lambda roles.
|
||||
auth_tables = {"users": names["users"], "sessions": names["sessions"],
|
||||
"password_resets": names["password_resets"]}
|
||||
resources["NovaIdpAuthRole"] = _lambda_role("NovaIdpAuth", auth_tables)
|
||||
resources["NovaIdpTokenVendRole"] = _lambda_role(
|
||||
"NovaIdpTokenVend", {"pats": names["pats"]}, kms=True)
|
||||
resources["NovaIdpJwksRole"] = _lambda_role("NovaIdpJwks", {}, kms=True)
|
||||
|
||||
# Lambda functions.
|
||||
common_env = {
|
||||
"NOVA_USERS_TABLE": names["users"],
|
||||
"NOVA_SESSIONS_TABLE": names["sessions"],
|
||||
"NOVA_PASSWORD_RESETS_TABLE": names["password_resets"],
|
||||
"NOVA_PATS_TABLE": names["pats"],
|
||||
}
|
||||
resources["NovaIdpAuthFunction"] = _lambda_function(
|
||||
"NovaIdpAuth", "nova_idp_auth.lambda_handler", "NovaIdpAuthRole", common_env)
|
||||
resources["NovaIdpTokenVendFunction"] = _lambda_function(
|
||||
"NovaIdpTokenVend", "nova_idp_token_vend.lambda_handler", "NovaIdpTokenVendRole",
|
||||
{**common_env, "NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"})
|
||||
resources["NovaIdpJwksFunction"] = _lambda_function(
|
||||
"NovaIdpJwks", "nova_idp_jwks.lambda_handler", "NovaIdpJwksRole",
|
||||
{"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"}, memory=256)
|
||||
|
||||
# Function URLs (auth Lambda: IAM; token-vend: IAM; jwks: NONE — public).
|
||||
resources["NovaIdpAuthUrl"] = _function_url("NovaIdpAuthFunction", "AWS_IAM")
|
||||
resources["NovaIdpTokenVendUrl"] = _function_url("NovaIdpTokenVendFunction", "AWS_IAM")
|
||||
resources["NovaIdpJwksUrl"] = _function_url("NovaIdpJwksFunction", "NONE")
|
||||
|
||||
# Optional: CloudFront + ACM + WAF for a custom JWKS domain.
|
||||
if public_jwks_domain:
|
||||
resources["NovaJwksCloudFront"] = {
|
||||
"Type": "AWS::CloudFront::Distribution",
|
||||
"Properties": {
|
||||
"DistributionConfig": {
|
||||
"Enabled": True,
|
||||
"Aliases": [public_jwks_domain],
|
||||
"Origins": [{
|
||||
"DomainName": {"Fn::GetAtt": "NovaIdpJwksUrl.Endpoint"},
|
||||
"Id": "JwksOrigin",
|
||||
"CustomOriginConfig": {"OriginProtocolPolicy": "https-only"},
|
||||
}],
|
||||
"DefaultCacheBehavior": {
|
||||
"TargetOriginId": "JwksOrigin",
|
||||
"ViewerProtocolPolicy": "redirect-to-https",
|
||||
"ForwardedValues": {"QueryString": False},
|
||||
},
|
||||
"ViewerCertificate": {
|
||||
"AcmCertificateArn": {"Ref": "NovaJwksAcmCert"},
|
||||
"SslSupportMethod": "sni-only",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
resources["NovaJwksAcmCert"] = {
|
||||
"Type": "AWS::CertificateManager::Certificate",
|
||||
"Properties": {"DomainName": public_jwks_domain,
|
||||
"ValidationMethod": "DNS"},
|
||||
}
|
||||
resources["NovaJwksWafRateRule"] = {
|
||||
"Type": "AWS::WAFv2::RateBasedRule",
|
||||
"Properties": {
|
||||
"Name": "nova-jwks-rate-limit",
|
||||
"Scope": "CLOUDFRONT",
|
||||
"RateLimit": 100,
|
||||
"Action": {"Block": {}},
|
||||
"ComparisonOperator": "GreaterThan",
|
||||
"AggregateKeyType": "IP",
|
||||
"DefaultCaptchaConfig": {"ImmunityTimeProperty": {"ImmunityTime": 60}},
|
||||
},
|
||||
}
|
||||
|
||||
return {"Resources": resources}
|
||||
|
||||
|
||||
def resource_summary(template: dict) -> dict[str, int]:
|
||||
"""Return ``{resource_type: count}`` for a template (for --dry-run)."""
|
||||
counts: dict[str, int] = {}
|
||||
for res in template.get("Resources", {}).values():
|
||||
t = res.get("Type", "Unknown")
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json, sys
|
||||
domain = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
print(json.dumps(generate_template(domain), indent=2))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Nova IdP setup logic — check / apply / verify (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``)
|
||||
is a thin ≤50-line delegate to this module (CAP-034).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_cfn():
|
||||
"""Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__file__).parent / "nova_idp_cfn.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_cfn", p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_cfn = _load_cfn()
|
||||
generate_template = _cfn.generate_template
|
||||
resource_summary = _cfn.resource_summary
|
||||
|
||||
|
||||
def check_prerequisites() -> dict[str, Any]:
|
||||
"""Check IdP setup prerequisites (AWS creds, CFN/IAM/KMS perms).
|
||||
|
||||
Returns a report dict:
|
||||
``{"aws_creds": bool, "region": str|None, "missing": [str], "iam_delta": [str]}``
|
||||
"""
|
||||
report: dict[str, Any] = {"aws_creds": False, "region": None, "missing": [], "iam_delta": []}
|
||||
# AWS creds check.
|
||||
try:
|
||||
who = subprocess.check_output(
|
||||
["aws", "sts", "get-caller-identity"], stderr=subprocess.DEVNULL, text=True, timeout=10
|
||||
)
|
||||
report["aws_creds"] = bool(json.loads(who).get("Account"))
|
||||
except Exception:
|
||||
report["missing"].append("aws_credentials (run `aws configure`)")
|
||||
# Region.
|
||||
region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION")
|
||||
report["region"] = region
|
||||
if not region:
|
||||
report["missing"].append("aws_region (set AWS_DEFAULT_REGION)")
|
||||
# IAM policy delta (the grants the deploying principal needs).
|
||||
report["iam_delta"] = [
|
||||
"cloudformation:*",
|
||||
"iam:CreateRole",
|
||||
"iam:PassRole",
|
||||
"lambda:CreateFunction",
|
||||
"lambda:CreateFunctionUrlConfig",
|
||||
"dynamodb:CreateTable",
|
||||
"kms:CreateKey",
|
||||
"kms:CreateAlias",
|
||||
]
|
||||
return report
|
||||
|
||||
|
||||
def generate_and_deploy(
|
||||
public_jwks_domain: str | None = None,
|
||||
dry_run: bool = False,
|
||||
approve_fn=None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate the CFN template + deploy (REQ-341, NFR-10 y/N approval).
|
||||
|
||||
Args:
|
||||
public_jwks_domain: optional custom JWKS domain.
|
||||
dry_run: if True, print the resource summary only (no deploy).
|
||||
approve_fn: callable returning True/False for the y/N prompt
|
||||
(defaults to stdin readline).
|
||||
|
||||
Returns:
|
||||
``{"template": <dict>, "summary": <dict>, "deployed": bool}``.
|
||||
"""
|
||||
template = generate_template(public_jwks_domain)
|
||||
summary = resource_summary(template)
|
||||
if dry_run:
|
||||
return {"template": template, "summary": summary, "deployed": False}
|
||||
# NFR-10: explicit y/N approval before cloudformation deploy.
|
||||
print("Resource summary:")
|
||||
for rtype, count in sorted(summary.items()):
|
||||
print(f" {rtype}: {count}")
|
||||
# Print template to a temp file + open $PAGER.
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8")
|
||||
json.dump(template, tmp, indent=2); tmp.flush(); tmp.close()
|
||||
pager = os.environ.get("PAGER")
|
||||
if pager and sys.stdin.isatty():
|
||||
try:
|
||||
subprocess.run([pager, tmp.name])
|
||||
except Exception:
|
||||
print(f"(template at {tmp.name})")
|
||||
else:
|
||||
print(f"(template at {tmp.name})")
|
||||
# y/N prompt.
|
||||
if approve_fn is None:
|
||||
answer = input("Apply? [y/N] ").strip().lower()
|
||||
else:
|
||||
answer = "y" if approve_fn() else "n"
|
||||
if answer != "y":
|
||||
print("aborted (no approval)")
|
||||
return {"template": template, "summary": summary, "deployed": False}
|
||||
# cloudformation deploy.
|
||||
stack_name = os.environ.get("NOVA_IDP_STACK_NAME", "nova-idp")
|
||||
try:
|
||||
subprocess.check_call([
|
||||
"aws", "cloudformation", "deploy",
|
||||
"--stack-name", stack_name,
|
||||
"--template-file", tmp.name,
|
||||
"--capabilities", "CAPABILITY_IAM",
|
||||
])
|
||||
deployed = True
|
||||
except Exception as e:
|
||||
print(f"deploy failed: {e}", file=sys.stderr)
|
||||
deployed = False
|
||||
return {"template": template, "summary": summary, "deployed": deployed}
|
||||
|
||||
|
||||
def verify() -> dict[str, Any]:
|
||||
"""Run the KMS round-trip verification (REQ-340 --verify).
|
||||
|
||||
Delegates to the CAP-037 test logic: sign a JWT (mock KMS) → JWKS →
|
||||
pyjwt verify. Returns ``{"passed": bool, "detail": str}``.
|
||||
"""
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
import core.kms_signing as kms_signing
|
||||
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub_der = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
class _MockKms:
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
return {"Signature": priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": pub_der}
|
||||
|
||||
kms_signing.set_kms_client_for_testing(_MockKms())
|
||||
token = kms_signing.sign_jwt({"sub": "verify", "exp": 9999999999, "iat": 1, "jti": "v"})
|
||||
jwk = kms_signing.get_jwk()
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||
ok = decoded["sub"] == "verify"
|
||||
return {"passed": ok, "detail": "KMS round-trip OK" if ok else "mismatch"}
|
||||
except Exception as e:
|
||||
return {"passed": False, "detail": f"verify error: {e}"}
|
||||
finally:
|
||||
try:
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "--check"
|
||||
if mode == "--check":
|
||||
print(json.dumps(check_prerequisites(), indent=2))
|
||||
elif mode == "--dry-run":
|
||||
print(json.dumps(generate_and_deploy(dry_run=True)["summary"], indent=2))
|
||||
elif mode == "--verify":
|
||||
print(json.dumps(verify(), indent=2))
|
||||
else:
|
||||
print("usage: nova_idp_setup.py --check|--dry-run|--verify", file=sys.stderr)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""nova idp — IdP setup subcommands (REQ-340, C-2.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("idp", help="Nova IdP management (setup)")
|
||||
sub = p.add_subparsers(dest="idp_command", required=True)
|
||||
from nova.idp import setup as _setup
|
||||
_setup.add_parser(sub)
|
||||
return p
|
||||
@@ -0,0 +1,40 @@
|
||||
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, C-2.1, ≤50 lines)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_setup():
|
||||
"""Load core/lambda/nova_idp_setup.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_setup.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_setup", p)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack")
|
||||
p.add_argument("--check", action="store_true", help="check prerequisites")
|
||||
p.add_argument("--apply", action="store_true", help="generate + deploy (NFR-10 y/N)")
|
||||
p.add_argument("--verify", action="store_true", help="run the KMS round-trip test")
|
||||
p.add_argument("--dry-run", action="store_true", help="resource summary only")
|
||||
p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
mod = _load_setup()
|
||||
if args.check:
|
||||
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
|
||||
if args.verify:
|
||||
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
if args.apply or args.dry_run:
|
||||
r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run)
|
||||
print(json.dumps(r["summary"], indent=2))
|
||||
return 0 if (r["deployed"] or args.dry_run) else 1
|
||||
print("usage: nova idp setup --check|--apply|--verify [--dry-run]", file=sys.stderr)
|
||||
return 2
|
||||
@@ -0,0 +1,191 @@
|
||||
"""nova idp setup tests (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Tests:
|
||||
* ``generate_template()`` produces a valid CFN dict with the expected
|
||||
resource types (3 Lambdas, 4 DDB tables, KMS key, 3 URLs, 3 roles).
|
||||
* ``--check`` (mock AWS) → prints a prerequisite report.
|
||||
* ``--dry-run`` → resource summary.
|
||||
* ``--apply`` (mock cloudformation deploy) → prompts + deploys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
|
||||
def _load(mod_name, rel_path):
|
||||
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_CFN_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_cfn.py"
|
||||
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||
cfn = _load("nova_idp_cfn_test", _CFN_PATH)
|
||||
setup = _load("nova_idp_setup_test", _SETUP_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_template_has_expected_resources():
|
||||
t = cfn.generate_template()
|
||||
res = t["Resources"]
|
||||
types = [r["Type"] for r in res.values()]
|
||||
assert types.count("AWS::Lambda::Function") == 3
|
||||
assert types.count("AWS::DynamoDB::Table") == 4
|
||||
assert types.count("AWS::KMS::Key") == 1
|
||||
assert types.count("AWS::KMS::Alias") == 1
|
||||
assert types.count("AWS::Lambda::Url") == 3
|
||||
assert types.count("AWS::IAM::Role") == 3
|
||||
|
||||
|
||||
def test_generate_template_kms_key_spec():
|
||||
t = cfn.generate_template()
|
||||
key = t["Resources"]["NovaOidcSigningKey"]["Properties"]
|
||||
assert key["KeySpec"] == "ECC_NIST_P256"
|
||||
assert key["KeyUsage"] == "SIGN_VERIFY"
|
||||
|
||||
|
||||
def test_generate_template_jwks_url_auth_none():
|
||||
"""JWKS function URL is AuthType NONE (public, REQ-338)."""
|
||||
t = cfn.generate_template()
|
||||
url = t["Resources"]["NovaIdpJwksUrl"]["Properties"]
|
||||
assert url["AuthType"] == "NONE"
|
||||
|
||||
|
||||
def test_generate_template_auth_url_iam():
|
||||
t = cfn.generate_template()
|
||||
url = t["Resources"]["NovaIdpAuthUrl"]["Properties"]
|
||||
assert url["AuthType"] == "AWS_IAM"
|
||||
|
||||
|
||||
def test_generate_template_public_domain_adds_cloudfront():
|
||||
t = cfn.generate_template(public_jwks_domain="jwks.example.com")
|
||||
types = [r["Type"] for r in t["Resources"].values()]
|
||||
assert "AWS::CloudFront::Distribution" in types
|
||||
assert "AWS::CertificateManager::Certificate" in types
|
||||
|
||||
|
||||
def test_resource_summary():
|
||||
t = cfn.generate_template()
|
||||
s = cfn.resource_summary(t)
|
||||
assert s["AWS::Lambda::Function"] == 3
|
||||
assert s["AWS::DynamoDB::Table"] == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_prerequisites_returns_report():
|
||||
with mock.patch("subprocess.check_output", side_effect=Exception("no creds")):
|
||||
report = setup.check_prerequisites()
|
||||
assert "aws_creds" in report
|
||||
assert report["aws_creds"] is False
|
||||
assert "missing" in report
|
||||
assert "iam_delta" in report
|
||||
assert "cloudformation:*" in report["iam_delta"]
|
||||
|
||||
|
||||
def test_check_prerequisites_with_creds():
|
||||
fake = json.dumps({"Account": "123456789012", "UserId": "u", "Arn": "arn"})
|
||||
with mock.patch("subprocess.check_output", return_value=fake):
|
||||
report = setup.check_prerequisites()
|
||||
assert report["aws_creds"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dry-run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dry_run_returns_summary():
|
||||
r = setup.generate_and_deploy(dry_run=True)
|
||||
assert r["deployed"] is False
|
||||
assert "AWS::Lambda::Function" in r["summary"]
|
||||
assert r["summary"]["AWS::Lambda::Function"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --apply (mock cloudformation deploy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_aborts_without_approval():
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: False)
|
||||
assert r["deployed"] is False
|
||||
|
||||
|
||||
def test_apply_deploys_with_approval():
|
||||
with mock.patch("subprocess.check_call", return_value=0):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is True
|
||||
|
||||
|
||||
def test_apply_deploy_failure_returns_not_deployed():
|
||||
with mock.patch("subprocess.check_call", side_effect=RuntimeError("cfn error")):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_roundtrip_passes():
|
||||
r = setup.verify()
|
||||
assert r["passed"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI wrapper (nova/idp/setup.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_setup_check(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = True; args.apply = False; args.verify = False; args.dry_run = False
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "aws_creds" in out
|
||||
|
||||
|
||||
def test_cli_setup_dry_run(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = False; args.apply = False; args.verify = False; args.dry_run = True
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "AWS::Lambda::Function" in out
|
||||
|
||||
|
||||
def test_cli_setup_verify(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = False; args.apply = False; args.verify = True; args.dry_run = False
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
Reference in New Issue
Block a user