Integration Test Strategy¶
Overview¶
This project tests against a real AWS account — no mocks, no LocalStack. A dedicated IAM User and IAM Role are provisioned with minimal permissions using AWS CDK. The test suite authenticates with real credentials and exercises actual STS / IAM API calls, giving us confidence that the library works end-to-end with AWS.
The overall flow:
CDK creates the IAM User and Role (infrastructure).
Python scripts create an access key for the User and write it to a local
.envfile (credentials never touch CloudFormation).Another script uploads the same credentials to GitHub Actions secrets so CI can authenticate.
pytest reads the credentials (from env vars in CI, from
.envlocally) and runs integration tests.
Constants¶
All test-related constants (IAM user/role names, GitHub secret names) live in a single file so that CDK, access-key scripts, CI config, and test code all reference the same values:
# -*- coding: utf-8 -*-
# fmt: off
ENV_VAR_NAME_FOR_AWS_PROFILE_FOR_CDK = "AWS_PROFILE_FOR_CDK"
TEST_IAM_USER_NAME = "project-boto_session_manager"
TEST_IAM_ROLE_NAME = "project-boto_session_manager"
GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR = "AWS_ACCESS_KEY_ID_FOR_GITHUB_CI"
GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR = "AWS_SECRET_ACCESS_KEY_FOR_GITHUB_CI"
# fmt: on
def load_bsm_project():
import os
from dotenv import load_dotenv
from ..manager import BotoSesManager
load_dotenv()
bsm_project = BotoSesManager(
aws_access_key_id=os.environ[GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR],
aws_secret_access_key=os.environ[GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR],
region_name="us-east-1",
)
return bsm_project
IAM Infrastructure (CDK)¶
The CDK stack lives in the cdk/ directory. It creates:
IAM User — program-only access, with an inline policy that allows
sts:AssumeRole(scoped to the test role),sts:GetCallerIdentity,sts:GetAccessKeyInfo, andiam:ListAccountAliases.IAM Role — trusted by the account root principal, with the same STS / IAM read permissions. Tests use
assume_role()to switch into this role.
The CDK stack does not manage access keys — those are created separately (see next section) so that the secret key never gets persisted in CloudFormation state.
The AWS profile used for CDK operations is defined in mise.toml:
# See claude code environment variables documents at https://code.claude.com/docs/en/settings
[env]
# This project uses real AWS IAM User/Role for integration testing.
# This profile is used by CDK to create and manage those IAM resources.
AWS_PROFILE_FOR_CDK = "bmt_app_dev_us_east_1"
The CDK app:
#!/usr/bin/env python3
import aws_cdk as cdk
import aws_cdk.aws_iam as iam
from constructs import Construct
from boto_session_manager.tests.settings import TEST_IAM_USER_NAME
from boto_session_manager.tests.settings import TEST_IAM_ROLE_NAME
class Stack(cdk.Stack):
def __init__(
self,
scope: Construct,
prefix: str,
**kwargs,
) -> None:
self.prefix = prefix
self.prefix_snake = prefix.replace("-", "_")
self.prefix_slug = prefix.replace("_", "-")
super().__init__(scope=scope, id=self.prefix_slug, **kwargs)
# --- IAM User (for integration tests) ---
self.iam_user = iam.User(
scope=self,
id="IamUser",
user_name=TEST_IAM_USER_NAME,
)
# inline policy: allow sts/iam read + assume the test role
self.iam_user.add_to_policy(
iam.PolicyStatement(
sid="AllowAssumeTestRole",
actions=["sts:AssumeRole"],
resources=[
f"arn:aws:iam::{cdk.Aws.ACCOUNT_ID}:role/{TEST_IAM_ROLE_NAME}"
],
)
)
self.iam_user.add_to_policy(
iam.PolicyStatement(
sid="AllowStsAndIamRead",
actions=[
"sts:GetAccessKeyInfo",
"sts:GetCallerIdentity",
"iam:ListAccountAliases",
],
resources=["*"],
)
)
# --- IAM Role (assumed by the user during tests) ---
self.iam_role = iam.Role(
scope=self,
id="IamRole",
role_name=TEST_IAM_ROLE_NAME,
assumed_by=iam.AccountRootPrincipal(),
)
self.iam_role.add_to_policy(
iam.PolicyStatement(
sid="AllowStsAndIamRead",
actions=[
"sts:GetAccessKeyInfo",
"sts:GetCallerIdentity",
"iam:ListAccountAliases",
],
resources=["*"],
)
)
app = cdk.App()
Stack(
scope=app,
prefix="boto_session_manager-project",
)
app.synth()
Access Key Management¶
Access keys are managed by Python scripts using boto3, not by CDK. This
ensures the secret key only ever appears in the create-access-key API
response and is written directly to the local .env file — it never gets
stored in CloudFormation outputs or state.
The core logic lives in cdk/iam_access_key.py:
# -*- coding: utf-8 -*-
"""
Manage IAM access keys for the integration-test user.
The ``AWS_PROFILE_FOR_CDK`` env var (set by mise.toml) controls which AWS
profile is used for all IAM operations.
"""
import os
from pathlib import Path
import boto3
from boto_session_manager.tests.settings import TEST_IAM_USER_NAME
from boto_session_manager.tests.settings import ENV_VAR_NAME_FOR_AWS_PROFILE_FOR_CDK
from boto_session_manager.tests.settings import GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR
from boto_session_manager.tests.settings import GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR
ENV_FILE = Path(__file__).parent.parent / ".env" # MAKE SURE THIS IS RIGHT
def _get_iam_client():
# THIS IS FROM mise.toml
aws_profile = os.environ.get(ENV_VAR_NAME_FOR_AWS_PROFILE_FOR_CDK)
session = boto3.Session(profile_name=aws_profile)
return session.client("iam")
def create_access_key_and_write_env() -> None:
"""Delete any existing access keys, create a fresh one, write to ``.env``."""
iam = _get_iam_client()
# delete existing keys to avoid the 2-key-per-user limit
existing = iam.list_access_keys(UserName=TEST_IAM_USER_NAME)
for meta in existing["AccessKeyMetadata"]:
iam.delete_access_key(
UserName=TEST_IAM_USER_NAME,
AccessKeyId=meta["AccessKeyId"],
)
print(f"deleted access key {meta['AccessKeyId']}")
# create a fresh key — secret key only appears in this response
resp = iam.create_access_key(UserName=TEST_IAM_USER_NAME)
ak = resp["AccessKey"]
ENV_FILE.write_text(
f"{GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR}={ak['AccessKeyId']}\n"
f"{GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR}={ak['SecretAccessKey']}\n"
)
print(f"wrote credentials to {ENV_FILE}")
def delete_access_key_and_remove_env() -> None:
"""Delete all access keys for the user and remove ``.env``."""
iam = _get_iam_client()
existing = iam.list_access_keys(UserName=TEST_IAM_USER_NAME)
for meta in existing["AccessKeyMetadata"]:
iam.delete_access_key(
UserName=TEST_IAM_USER_NAME,
AccessKeyId=meta["AccessKeyId"],
)
print(f"deleted access key {meta['AccessKeyId']}")
if ENV_FILE.exists():
ENV_FILE.unlink()
print(f"removed {ENV_FILE}")
Two thin entry-point scripts import and call the functions above:
Create — deletes any existing keys, creates a fresh one, writes .env:
# -*- coding: utf-8 -*-
"""
Create a fresh IAM access key and write credentials to .env.
Usage: python cdk/create_access_key.py
Requires the ``AWS_PROFILE_FOR_CDK`` env var (set by mise.toml).
"""
from iam_access_key import create_access_key_and_write_env
create_access_key_and_write_env()
Delete — removes all keys and deletes .env:
# -*- coding: utf-8 -*-
"""
Delete all IAM access keys for the test user and remove .env.
Usage: python cdk/delete_access_key.py
Requires the ``AWS_PROFILE_FOR_CDK`` env var (set by mise.toml).
"""
from iam_access_key import delete_access_key_and_remove_env
delete_access_key_and_remove_env()
GitHub Actions Secrets¶
After the access key is written to .env, a separate script reads it and
uploads the credentials to GitHub Actions secrets using the PyGithub library.
This is how CI gets the credentials without checking them into the repo.
# -*- coding: utf-8 -*-
"""
Upload IAM access key credentials from .env to GitHub Actions secrets.
Reads ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` from ``.env``,
then creates/updates the corresponding GitHub Actions secrets:
- ``AWS_ACCESS_KEY_ID_FOR_GITHUB_CI``
- ``AWS_SECRET_ACCESS_KEY_FOR_GITHUB_CI``
Required environment variables (set by mise.toml):
- GITHUB_TOKEN: GitHub personal access token with repo scope
Usage: python cdk/setup_github_action_secret.py
"""
import os
import sys
try:
from github import Github, GithubException, Auth
except ImportError:
print("Error: PyGithub not installed. Run: uv sync --extra mise")
sys.exit(1)
from dotenv import load_dotenv
from boto_session_manager.tests.settings import GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR
from boto_session_manager.tests.settings import GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR
from utils import get_github_repo_info
def main():
github_token = os.environ.get("GITHUB_TOKEN")
if not github_token:
print("Error: GITHUB_TOKEN environment variable not set")
sys.exit(1)
owner, repo_name = get_github_repo_info()
repo_fullname = f"{owner}/{repo_name}"
print(f"Updating GitHub Actions secrets for: {repo_fullname}")
gh = Github(auth=Auth.Token(github_token))
try:
repo = gh.get_repo(repo_fullname)
except GithubException as e:
print(f"Error: Could not access repository {repo_fullname}: {e}")
sys.exit(1)
load_dotenv()
repo.create_secret(
secret_name=GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR,
unencrypted_value=os.environ[GH_CI_AWS_ACCESS_KEY_ID_ENV_VAR],
secret_type="actions",
)
repo.create_secret(
secret_name=GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR,
unencrypted_value=os.environ[GH_CI_AWS_SECRET_ACCESS_KEY_ENV_VAR],
secret_type="actions",
)
print("done")
if __name__ == "__main__":
main()
The CI workflow references these secrets in .github/workflows/main.yml:
# Run pytest with coverage reporting
- name: "Run pytest"
env:
# these two env var name should match the variable defined in
# .mise/tasks/setup_github_action_secret.py
AWS_ACCESS_KEY_ID_FOR_GITHUB_CI: ${{ secrets.AWS_ACCESS_KEY_ID_FOR_GITHUB_CI }}
AWS_SECRET_ACCESS_KEY_FOR_GITHUB_CI: ${{ secrets.AWS_SECRET_ACCESS_KEY_FOR_GITHUB_CI }}
run: "uv run pytest tests --cov=${{ env.PACKAGE_NAME }} --cov-report=xml --cov-report term-missing"
mise Tasks — Putting It All Together¶
The mise.toml file defines two tasks that orchestrate the full lifecycle:
cdk-up — deploy infrastructure, create access key, push secrets to GitHub:
[tasks.cdk-up]
description = "🟢 Deploy CDK stacks, create access key, write to .env and GitHub secrets"
dir = "cdk"
depends = ["inst"]
run = """
cdk deploy --all --require-approval never --profile $AWS_PROFILE_FOR_CDK
../.venv/bin/python create_access_key.py
../.venv/bin/python ../.mise/tasks/setup_github_action_secret.py
"""
cdk-down — delete access key and .env, then destroy the CDK stack:
[tasks.cdk-down]
description = "🔴 Delete access key and .env, then destroy CDK stacks"
dir = "cdk"
run = """
../.venv/bin/python delete_access_key.py
cdk destroy --all --force --profile $AWS_PROFILE_FOR_CDK
"""
Typical usage:
# first time setup (or after rotating credentials)
mise run cdk-up
# run tests locally
mise run cov
# tear everything down
mise run cdk-down
Test Code¶
The test file tests/test_manager.py handles both local and CI
environments. It detects the CI environment variable to decide where
to read credentials from:
CI: reads from GitHub Actions secrets (injected as env vars).
Local: uses
python-dotenvto load from the.envfile created bycreate_access_key.py.
# -*- coding: utf-8 -*-
from boto_session_manager.manager import BotoSesManager
from boto_session_manager.manager import AwsServiceEnum
from boto_session_manager.manager import PATH_DEFAULT_SNAPSHOT
import pytest
import os
import json
import subprocess
# fmt: off
from boto_session_manager.tests.settings import TEST_IAM_USER_NAME
from boto_session_manager.tests.settings import TEST_IAM_ROLE_NAME
from boto_session_manager.tests.settings import load_bsm_project
if "CI" in os.environ: # pragma: no cover
IS_CI = True
# to test this on your local, make sure your DEFAULT AWS profile
# is NOT project_boto_session_manager
# and also the aws account is NOT the same as project_boto_session_manager
else: # pragma: no cover
IS_CI = False
bsm_project = load_bsm_project()
class TestBotoSesManager:
def test_aws_account_id_and_region(self):
_ = bsm_project.aws_account_user_id
_ = bsm_project.masked_aws_account_user_id
_ = bsm_project.aws_account_id
_ = bsm_project.masked_aws_account_id
_ = bsm_project.principal_arn
_ = bsm_project.masked_principal_arn
_ = bsm_project.aws_region
_ = bsm_project.aws_account_alias
bsm_assumed = bsm_project.assume_role(
role_arn=f"arn:aws:iam::{bsm_project.aws_account_id}:role/{TEST_IAM_ROLE_NAME}"
)
_ = bsm_assumed.aws_account_user_id
_ = bsm_assumed.masked_aws_account_user_id