afterhours-shift-manager/tests/infra/test_hcp_contract.py
Adam Moussa c611fd5d2b
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
feat(api): add OpenAPI Redocly contract and VPC outputs (DEV-289) (#268)
* feat(infra): export vpc_id and public subnet outputs (DEV-289)

Portal Fargate and meals already attach to this VPC. These outputs are
the HCP existing_vpc_id / existing_public_subnet_ids values.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* feat(api): add OpenAPI 3.1 and Redocly lint in CI (DEV-289)

Same extends: recommended ruleset and @redocly/cli 2.52.1 as
internal-portal. Covers health, roster, and portal /api/shifts.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): document 4xx and treat 302 as success in Redocly (DEV-289)

Health and CORS preflight document 400. Recommended only counted 2XX,
so login-style 302s use a shared 2XX-or-3XX rule.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): fail Redocly on missing 4xx and 2xx/3xx (DEV-289)

Promote operation-4xx-response and the 2xx-or-3xx success rule to error.
Drop unused 400s on health and CORS OPTIONS. Health documents 403 like the
portal. CORS stays in Flask and is not part of the employee contract.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-22 00:34:15 +00:00

221 lines
8.4 KiB
Python

"""Contracts for the HCP Terraform seam (PLAT-74 / PLAT-216)."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TERRAFORM = ROOT / "terraform"
HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
LOCALS = (TERRAFORM / "locals.tf").read_text()
VARIABLES = (TERRAFORM / "variables.tf").read_text()
def _tf_without_comments(text: str) -> str:
return "\n".join(line.split("#", 1)[0] for line in text.splitlines())
def test_sam_template_removed():
assert not (ROOT / "template.yaml").exists()
assert not (ROOT / "samconfig.toml.example").exists()
def test_zip_cd_removed():
assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists()
for path in TERRAFORM.glob("*.tf"):
assert 'resource "aws_lambda_function"' not in path.read_text()
assert not (TERRAFORM / "apigateway.tf").exists()
assert not (TERRAFORM / "events.tf").exists()
def test_schedules_disabled_by_default():
chunk = (
(TERRAFORM / "variables.tf")
.read_text()
.split('variable "schedules_enabled"')[1]
)
chunk = chunk.split("variable ")[0]
assert "default = false" in chunk or "default = false" in chunk
def test_ecs_schedules_disabled_by_default():
chunk = (
(TERRAFORM / "variables.tf")
.read_text()
.split('variable "ecs_schedules_enabled"')[1]
)
chunk = chunk.split("variable ")[0]
assert "default = false" in chunk or "default = false" in chunk
def test_workspaces_use_app_tag():
versions = (TERRAFORM / "versions.tf").read_text()
assert 'tags = ["app:afterhours-shift-manager"]' in versions
assert 'name = "afterhours-shift-manager-prod"' not in versions
assert 'hcp_workspace = "${local.project}-${var.environment}"' in LOCALS
assert "seahaven-${var.environment}" in LOCALS
def test_ecs_ignore_changes_and_task_size():
ecs = (TERRAFORM / "ecs.tf").read_text()
assert "ignore_changes = [container_definitions]" in ecs
assert "ignore_changes = [task_definition, desired_count]" in ecs
assert 'cpu = "512"' in ecs
assert 'memory = "1024"' in ecs
assert 'cpu_architecture = "ARM64"' in ecs
assert 'path = "/api/health"' in ecs
def test_stack_owns_a_vpc_instead_of_looking_up_default():
vpc = (TERRAFORM / "vpc.tf").read_text()
ecs = (TERRAFORM / "ecs.tf").read_text()
assert 'resource "aws_vpc" "this"' in vpc
assert "cidr_block = local.vpc_cidr" in vpc
assert 'vpc_cidr = "10.70.0.0/16"' in LOCALS
assert 'data "aws_vpc" "default"' not in ecs
assert "data.aws_vpc.default" not in ecs
assert "data.aws_subnets.default" not in ecs
assert "aws_vpc.this.id" in ecs
assert "aws_subnet.public[*].id" in ecs
assert "ec2:CreateVpc" in HCP_IAM
assert "sid = \"RefreshVpc\"" in HCP_IAM
assert "afterhours-shift-manager-ecs" in HCP_IAM
assert "hcptf_apply_ecs" in HCP_IAM
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
assert "sid = \"CreateElbAndEcsServiceLinkedRoles\"" in HCP_IAM
assert "iam:CreateServiceLinkedRole" in HCP_IAM
def test_ecs_task_boundary_uses_static_arns():
iam = (TERRAFORM / "iam.tf").read_text()
chunk = iam.split('data "aws_iam_policy_document" "ecs_task_boundary"')[1]
chunk = chunk.split("resource ")[0]
assert "aws_dynamodb_table.shifts" not in chunk
assert "aws_sqs_queue.jobs" not in chunk
assert "aws_ecr_repository.api" not in chunk
assert "aws_cloudwatch_log_group.api" not in chunk
assert "table/${local.table_name}" in chunk
assert "log-group:/ecs/${local.project}" in chunk
def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
assert "linux/arm64" in deploy_api
assert "gh release create" in deploy_api
def test_in_repo_hcptf_roles():
assert 'apply_role = "hcptf-afterhours-shift-manager"' in LOCALS
assert 'plan_role = "hcptf-afterhours-shift-manager-plan"' in LOCALS
assert "hcptf_apply" in HCP_IAM
assert "DenyCreatePolicy" in HCP_IAM
def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI
assert "pytest" in CI
assert "terraform fmt -check" in CI
assert "terraform init -backend=false" in CI
assert "terraform validate" in CI
assert "openapi:lint" in CI
assert "npm ci" in CI
def test_openapi_uses_redocly_recommended():
redocly = (ROOT / ".redocly.yaml").read_text()
package = (ROOT / "package.json").read_text()
spec = (ROOT / "openapi.yaml").read_text()
assert "extends:" in redocly
assert "- recommended" in redocly
assert "operation-2xx-response: off" in redocly
assert "operation-4xx-response: error" in redocly
assert "rule/operation-2xx-or-3xx-response" in redocly
assert "severity: error" in redocly
assert "optionsShifts" not in spec
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
assert '"403":' in health
assert '"400":' not in health
assert 'root: openapi.yaml' in redocly
assert '"openapi:lint"' in package
assert '"@redocly/cli": "2.52.1"' in package
assert "openapi: 3.1.0" in spec
assert "required: [stage, sha]" in spec
def test_checkcomponents_queue_arn_variable_matches_iam_references():
assert 'variable "checkcomponents_queue_arn"' in VARIABLES
iam = (TERRAFORM / "iam.tf").read_text()
assert "var.checkcomponents_queue_arn" in iam
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
assert "var.checkcomponents_queue_arn" in boundary
data_tf = (TERRAFORM / "data.tf").read_text()
assert "dev_has_no_paychex" in data_tf
assert "checkcomponents_pair" in data_tf
def test_leftover_lambda_iam_roles_removed():
for path in TERRAFORM.glob("*.tf"):
body = _tf_without_comments(path.read_text())
assert 'resource "aws_iam_role" "lambda"' not in body
assert 'resource "aws_iam_role_policy" "lambda"' not in body
assert 'resource "aws_iam_role_policy_attachment" "lambda_basic"' not in body
assert 'data "aws_iam_policy_document" "lambda_assume"' not in body
def test_lambda_boundary_policy_remains():
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
assert 'resource "aws_iam_policy" "lambda_boundary"' in boundary
assert "afterhours-shift-manager-lambda-boundary" in boundary
def test_local_functions_still_lists_packaging_keys():
for name in (
"afterhours-shift-manager",
"afterhours-weekly-post",
"afterhours-roster-sync",
"afterhours-roster-api",
"afterhours-ring-scheduler",
"afterhours-holiday-router",
"afterhours-portal-api",
):
assert name in LOCALS
assert "afterhours-release-notifier" not in LOCALS
assert "weekly_post" in LOCALS
def test_github_deploy_trust_covers_image_only():
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
assert "environment:prod" in iam or "environment:prod" in LOCALS
assert "environment:dev" in iam or "environment:dev" in LOCALS
assert "deploy.yaml@" not in iam
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
assert "deploy-api.yaml@refs/tags/v*" in iam
assert "ecs:ListTasks" in iam
def test_plan_refresh_includes_provider6_s3_gets():
assert "s3:GetLifecycleConfiguration" in HCP_IAM
assert "s3:GetReplicationConfiguration" in HCP_IAM
assert "s3:GetBucketReplication" in HCP_IAM
def test_origins_use_fargate_url():
outputs = (TERRAFORM / "outputs.tf").read_text()
assert "aws_apigatewayv2_api.http" not in outputs
assert '${local.api_url}/slack/events' in outputs
assert "value = local.api_url" in outputs
assert "output \"vpc_id\"" in outputs
assert "output \"public_subnet_ids\"" in outputs
assert "aws_vpc.this.id" in outputs
def test_alb_alarms_remain():
alarms = (TERRAFORM / "alarms.tf").read_text()
assert "ALB-5xx-" in alarms
assert "ALB-Latency-" in alarms
assert "ALB-UnhealthyHost-" in alarms
assert "ApiGateway-" not in alarms
assert "Lambda-" not in alarms