diff --git a/core/lambda/nova_idp_cfn.py b/core/lambda/nova_idp_cfn.py new file mode 100644 index 0000000..d4919b6 --- /dev/null +++ b/core/lambda/nova_idp_cfn.py @@ -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)) \ No newline at end of file diff --git a/core/lambda/nova_idp_setup.py b/core/lambda/nova_idp_setup.py new file mode 100644 index 0000000..3a8f2e0 --- /dev/null +++ b/core/lambda/nova_idp_setup.py @@ -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": , "summary": , "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) \ No newline at end of file diff --git a/nova/idp/__init__.py b/nova/idp/__init__.py new file mode 100644 index 0000000..ba53cbb --- /dev/null +++ b/nova/idp/__init__.py @@ -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 \ No newline at end of file diff --git a/nova/idp/setup.py b/nova/idp/setup.py new file mode 100644 index 0000000..3c97bf8 --- /dev/null +++ b/nova/idp/setup.py @@ -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 \ No newline at end of file diff --git a/tests/test_idp_setup.py b/tests/test_idp_setup.py new file mode 100644 index 0000000..adacde7 --- /dev/null +++ b/tests/test_idp_setup.py @@ -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 \ No newline at end of file