Skip to content

CI/CD Integration and Security Testing

CI/CD Integration

Pipeline Test Stages

Recommended stage order for fast feedback:

Commit push
  │
  ├─► [1] Lint + Type check      (< 30 s)
  ├─► [2] Unit tests             (< 2 min)
  ├─► [3] Integration tests      (< 5 min)
  ├─► [4] API / Contract tests   (< 5 min)
  ├─► [5] E2E smoke              (< 10 min, on staging)
  └─► [6] Security scan          (nightly or pre-release)

Rule: fail early. A lint failure should not trigger integration tests.

GitHub Actions Example

name: Tests

on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync
      - run: uv run pytest -m "not slow and not e2e" --cov=src --cov-report=xml

  integration:
    needs: unit
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
        options: --health-cmd pg_isready
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync
      - run: uv run pytest -m integration
        env:
          TEST_DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb

Fast Feedback

Technique Benefit
Stage gating Don't run slow tests after fast test fails
Parallel workers (-n auto) Reduce wall-clock time
Test result caching Skip unchanged test files
Mark-based selection Run only affected tests on PR

Test Reports

Generate machine-readable reports:

uv run pytest --junitxml=reports/junit.xml --html=reports/report.html --cov=src --cov-report=html

Upload in CI:

- uses: actions/upload-artifact@v4
  with:
    name: test-reports
    path: reports/


Security Testing

Input Validation Testing

Test that the API rejects malformed, oversized, or unexpected input:

import pytest

INVALID_PAYLOADS = [
    {"email": ""},                              # empty required field
    {"email": "a" * 300 + "@example.com"},      # oversized value
    {"email": None},                            # wrong type
    {},                                         # missing required fields
    {"email": "valid@test.com", "extra": "x"},  # unexpected field
]

@pytest.mark.parametrize("payload", INVALID_PAYLOADS)
def test_rejects_invalid_user_payload(api_client, payload):
    response = api_client.post("/users", json=payload)
    assert response.status_code in (400, 422)

Auth Testing

def test_unauthenticated_request_returns_401(unauthenticated_client):
    response = unauthenticated_client.get("/users")
    assert response.status_code == 401

def test_insufficient_role_returns_403(viewer_client):
    response = viewer_client.delete("/users/some-id")
    assert response.status_code == 403

def test_expired_token_returns_401(api_client):
    expired_token = generate_expired_token()
    response = api_client.get("/users", headers={"Authorization": f"Bearer {expired_token}"})
    assert response.status_code == 401

Injection Testing

SQL_INJECTION_PAYLOADS = [
    "'; DROP TABLE users; --",
    "1 OR 1=1",
    "admin'--",
]

@pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
@pytest.mark.security
def test_sql_injection_rejected(api_client, payload):
    response = api_client.get("/users", params={"search": payload})
    assert response.status_code in (200, 400)
    if response.status_code == 200:
        body = response.json()
        assert "DROP TABLE" not in str(body)
        assert "syntax error" not in str(body).lower()

Security Test Checklist

Check Expected Result
No token → 401 401 Unauthorized
Wrong role → 403 403 Forbidden
Expired token → 401 401 Unauthorized
SQL injection in query param 400 or sanitised result
XSS in string field Field stored safely, not executed
Oversized payload 413 or 422
Enumeration via sequential IDs 403 or obfuscated IDs
Missing CSRF token on state mutation 403
Sensitive data in error response No stack trace / PII in 4xx/5xx