diff --git a/.github/workflows/RELEASE_GUIDE.md b/.github/workflows/RELEASE_GUIDE.md deleted file mode 100644 index 4f7e232b..00000000 --- a/.github/workflows/RELEASE_GUIDE.md +++ /dev/null @@ -1,89 +0,0 @@ -# Release Management Guide - -How to create releases with versioned Docker images. - -## Creating a Release - -### Method 1: Git Tag (Recommended) - -```bash -git checkout main -git pull origin main -git tag -a v1.0.0 -m "Release v1.0.0" -git push origin v1.0.0 -``` - -This triggers `release.yml` which builds all services and creates a GitHub Release. - -### Method 2: Manual Dispatch - -1. Actions → **Release - Build and Publish All Services** -2. Click **Run workflow** -3. Enter version: `v1.0.0` (must start with `v`) - -## Version Tags - -Tagging `v1.2.3` creates these image tags for each service: - -- `v1.2.3` - Exact version -- `v1.2` - Minor version -- `v1` - Major version -- `latest` - Latest release - -**Example:** - -``` -ghcr.io/{owner}/{repo}/portal-backend:v1.2.3 -ghcr.io/{owner}/{repo}/portal-backend:v1.2 -ghcr.io/{owner}/{repo}/portal-backend:v1 -ghcr.io/{owner}/{repo}/portal-backend:latest -``` - -## Semantic Versioning - -- **MAJOR** (v1.0.0 → v2.0.0): Breaking changes -- **MINOR** (v1.0.0 → v1.1.0): New features -- **PATCH** (v1.0.0 → v1.0.1): Bug fixes - -## Using Release Images - -**Production:** - -```yaml -services: - portal-backend: - image: ghcr.io/{owner}/{repo}/portal-backend:v1.0.0 -``` - -**Development:** - -```yaml -services: - portal-backend: - image: ghcr.io/{owner}/{repo}/portal-backend:latest -``` - -## Rollback - -Update docker-compose.yml to previous version: - -```yaml -services: - portal-backend: - image: ghcr.io/{owner}/{repo}/portal-backend:v1.0.0 -``` - -Then: `docker compose pull && docker compose up -d` - -## Troubleshooting - -**Workflow doesn't trigger:** - -- Tag format must match `v*.*.*` pattern (e.g., `v1.0.0`, `v2.1.3`) -- Ensure tag was pushed: `git push origin v1.0.0` - -**Security scan fails:** - -- Review vulnerabilities in Security tab -- Update dependencies/base images -- Re-run workflow after fixes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38f03ff3..08c203c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,11 @@ name: Release - Build and Publish All Services on: push: tags: - - 'v*.*.*' # Matches semantic version tags like v1.0.0, v2.1.3, etc. + - "v*.*.*" # Matches semantic version tags like v1.0.0, v2.1.3, etc. workflow_dispatch: inputs: version: - description: 'Release version (e.g., v1.0.0)' + description: "Release version (e.g., v1.0.0)" required: true type: string @@ -21,12 +21,41 @@ jobs: contents: write packages: write security-events: write + env: + GIT_COMMIT: ${{ github.sha }} steps: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 # Fetch all history for proper versioning + fetch-depth: 0 # Fetch all history for proper versioning + + - name: Set Build Time + id: build-time + run: | + # Fallback logic: head_commit.timestamp -> repository.pushed_at -> current time + TIMESTAMP="${{ github.event.head_commit.timestamp }}" + if [ -z "$TIMESTAMP" ]; then + TIMESTAMP="${{ github.event.repository.pushed_at }}" + fi + if [ -z "$TIMESTAMP" ]; then + TIMESTAMP=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + fi + echo "build_time=${TIMESTAMP}" >> $GITHUB_OUTPUT + echo "Build time set to: $TIMESTAMP" + + - name: Determine if this is a dry run + id: dry-run + run: | + # Only push images for tag pushes on main branch or manual dispatch + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == refs/tags/* ]] || \ + [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "enabled=true" >> $GITHUB_OUTPUT + echo "✅ Real release mode - will push images" + else + echo "enabled=false" >> $GITHUB_OUTPUT + echo "🧪 DRY RUN mode - will build but NOT push images" + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -46,167 +75,427 @@ jobs: else VERSION="${GITHUB_REF#refs/tags/}" fi - # Remove 'v' prefix if present - VERSION=${VERSION#v} - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "full_version=v${VERSION}" >> $GITHUB_OUTPUT - echo "Extracted version: ${VERSION}" + # Remove leading 'v' if present + VERSION="${VERSION#v}" + echo "tag=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version tag: ${VERSION}" - - name: Build and push Portal Backend - uses: docker/build-push-action@v5 + - name: Extract metadata for Portal Backend + id: meta-portal + uses: docker/metadata-action@v5 with: - context: ./portal-backend - push: true + images: ${{ env.REGISTRY }}/${{ github.repository }}/portal-backend tags: | - ${{ env.REGISTRY }}/${{ github.repository }}/portal-backend:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/portal-backend:latest + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.description=Portal Backend service for the Government Data Exchange platform + org.opencontainers.image.description=Portal Backend service for the Data Exchange platform org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Portal Backend + uses: docker/build-push-action@v5 + with: + context: . + file: ./portal-backend/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-portal.outputs.tags }} + labels: ${{ steps.meta-portal.outputs.labels }} build-args: | SERVICE_PATH=portal-backend - BUILD_VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.pushed_at || github.run_started_at }} - GIT_COMMIT=${{ github.sha }} + BUILD_VERSION=${{ steps.version.outputs.tag }} + BUILD_TIME=${{ steps.build-time.outputs.build_time }} + GIT_COMMIT=${{ env.GIT_COMMIT }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Build and push Audit Service - uses: docker/build-push-action@v5 + - name: Extract metadata for Audit Service + id: meta-audit + uses: docker/metadata-action@v5 with: - context: ./audit-service - push: true + images: ${{ env.REGISTRY }}/${{ github.repository }}/audit-service tags: | - ${{ env.REGISTRY }}/${{ github.repository }}/audit-service:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/audit-service:latest + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.description=Audit Service for tracking and logging system events + org.opencontainers.image.description=Audit Service for tracking and logging events org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Audit Service + uses: docker/build-push-action@v5 + with: + context: . + file: ./audit-service/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-audit.outputs.tags }} + labels: ${{ steps.meta-audit.outputs.labels }} build-args: | SERVICE_PATH=audit-service - BUILD_VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.pushed_at || github.run_started_at }} - GIT_COMMIT=${{ github.sha }} + BUILD_VERSION=${{ steps.version.outputs.tag }} + BUILD_TIME=${{ steps.build-time.outputs.build_time }} + GIT_COMMIT=${{ env.GIT_COMMIT }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Build and push Policy Decision Point - uses: docker/build-push-action@v5 + - name: Extract metadata for Policy Decision Point + id: meta-pdp + uses: docker/metadata-action@v5 with: - context: ./exchange - file: ./exchange/policy-decision-point/Dockerfile - push: true + images: ${{ env.REGISTRY }}/${{ github.repository }}/policy-decision-point tags: | - ${{ env.REGISTRY }}/${{ github.repository }}/policy-decision-point:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/policy-decision-point:latest + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.description=Policy Decision Point service for authorization decisions org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Policy Decision Point + uses: docker/build-push-action@v5 + with: + context: . + file: ./exchange/policy-decision-point/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-pdp.outputs.tags }} + labels: ${{ steps.meta-pdp.outputs.labels }} build-args: | SERVICE_PATH=policy-decision-point - BUILD_VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.pushed_at || github.run_started_at }} - GIT_COMMIT=${{ github.sha }} + BUILD_VERSION=${{ steps.version.outputs.tag }} + BUILD_TIME=${{ steps.build-time.outputs.build_time }} + GIT_COMMIT=${{ env.GIT_COMMIT }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Build and push Consent Engine - uses: docker/build-push-action@v5 + - name: Extract metadata for Consent Engine + id: meta-consent + uses: docker/metadata-action@v5 with: - context: ./exchange - file: ./exchange/consent-engine/Dockerfile - push: true + images: ${{ env.REGISTRY }}/${{ github.repository }}/consent-engine tags: | - ${{ env.REGISTRY }}/${{ github.repository }}/consent-engine:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/consent-engine:latest + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.description=Consent Engine service for managing data consent workflows org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Consent Engine + uses: docker/build-push-action@v5 + with: + context: . + file: ./exchange/consent-engine/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-consent.outputs.tags }} + labels: ${{ steps.meta-consent.outputs.labels }} build-args: | SERVICE_PATH=consent-engine - BUILD_VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.pushed_at || github.run_started_at }} - GIT_COMMIT=${{ github.sha }} + BUILD_VERSION=${{ steps.version.outputs.tag }} + BUILD_TIME=${{ steps.build-time.outputs.build_time }} + GIT_COMMIT=${{ env.GIT_COMMIT }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Build and push Orchestration Engine - uses: docker/build-push-action@v5 + - name: Extract metadata for Orchestration Engine + id: meta-orchestration + uses: docker/metadata-action@v5 with: - context: ./exchange/orchestration-engine - push: true + images: ${{ env.REGISTRY }}/${{ github.repository }}/orchestration-engine tags: | - ${{ env.REGISTRY }}/${{ github.repository }}/orchestration-engine:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/orchestration-engine:latest + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.description=Orchestration Engine service for coordinating data exchange workflows org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Orchestration Engine + uses: docker/build-push-action@v5 + with: + context: . + file: ./exchange/orchestration-engine/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-orchestration.outputs.tags }} + labels: ${{ steps.meta-orchestration.outputs.labels }} build-args: | SERVICE_PATH=orchestration-engine - BUILD_VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.pushed_at || github.run_started_at }} - GIT_COMMIT=${{ github.sha }} + BUILD_VERSION=${{ steps.version.outputs.tag }} + BUILD_TIME=${{ steps.build-time.outputs.build_time }} + GIT_COMMIT=${{ env.GIT_COMMIT }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Run Trivy vulnerability scanner on all images + - name: Extract metadata for Admin Portal + id: meta-admin-portal + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/admin-portal + tags: | + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.description=Admin Portal for the Data Exchange platform + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Admin Portal + uses: docker/build-push-action@v5 + with: + context: ./portals/admin-portal + file: ./portals/admin-portal/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-admin-portal.outputs.tags }} + labels: ${{ steps.meta-admin-portal.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for Consent Portal + id: meta-consent-portal + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/consent-portal + tags: | + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.description=Consent Portal for the Data Exchange platform + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Consent Portal + uses: docker/build-push-action@v5 + with: + context: ./portals/consent-portal + file: ./portals/consent-portal/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-consent-portal.outputs.tags }} + labels: ${{ steps.meta-consent-portal.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for Member Portal + id: meta-member-portal + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/member-portal + tags: | + type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }} + type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest + type=sha,format=long + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.description=Member Portal for the Data Exchange platform + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.version=${{ steps.version.outputs.tag }} + + - name: Build and push Member Portal + uses: docker/build-push-action@v5 + with: + context: ./portals/member-portal + file: ./portals/member-portal/Dockerfile + push: ${{ steps.dry-run.outputs.enabled == 'true' }} + tags: ${{ steps.meta-member-portal.outputs.tags }} + labels: ${{ steps.meta-member-portal.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Trivy vulnerability scanner - Portal Backend + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-portal.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-portal-backend.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Audit Service + if: steps.dry-run.outputs.enabled == 'true' uses: aquasecurity/trivy-action@master continue-on-error: true with: - image-ref: | - ${{ env.REGISTRY }}/${{ github.repository }}/portal-backend:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/audit-service:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/policy-decision-point:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/consent-engine:${{ steps.version.outputs.full_version }} - ${{ env.REGISTRY }}/${{ github.repository }}/orchestration-engine:${{ steps.version.outputs.full_version }} - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH' - exit-code: '1' + image-ref: ${{ fromJSON(steps.meta-audit.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-audit-service.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Policy Decision Point + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-pdp.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-policy-decision-point.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Consent Engine + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-consent.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-consent-engine.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Orchestration Engine + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-orchestration.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-orchestration-engine.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Admin Portal + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-admin-portal.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-admin-portal.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Consent Portal + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-consent-portal.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-consent-portal.sarif" + severity: "CRITICAL,HIGH" + ignore-unfixed: false + + - name: Run Trivy vulnerability scanner - Member Portal + if: steps.dry-run.outputs.enabled == 'true' + uses: aquasecurity/trivy-action@master + continue-on-error: true + with: + image-ref: ${{ fromJSON(steps.meta-member-portal.outputs.json).tags[0] }} + format: "sarif" + output: "trivy-member-portal.sarif" + severity: "CRITICAL,HIGH" ignore-unfixed: false - name: Upload Trivy results to GitHub Security + if: steps.dry-run.outputs.enabled == 'true' && always() uses: github/codeql-action/upload-sarif@v3 - if: always() with: - sarif_file: 'trivy-results.sarif' + sarif_file: | + trivy-portal-backend.sarif + trivy-audit-service.sarif + trivy-policy-decision-point.sarif + trivy-consent-engine.sarif + trivy-orchestration-engine.sarif + trivy-admin-portal.sarif + trivy-consent-portal.sarif + trivy-member-portal.sarif - name: Create GitHub Release uses: softprops/action-gh-release@v1 if: github.event_name == 'push' with: tag_name: ${{ github.ref }} - name: Release ${{ steps.version.outputs.full_version }} + name: Release ${{ steps.version.outputs.tag }} body: | - ## Release ${{ steps.version.outputs.full_version }} + ## Release ${{ steps.version.outputs.tag }} - This release includes Docker images for all services: + This release includes Docker images for all services and portals: - - **Portal Backend**: `ghcr.io/${{ github.repository }}/portal-backend:${{ steps.version.outputs.full_version }}` - - **Audit Service**: `ghcr.io/${{ github.repository }}/audit-service:${{ steps.version.outputs.full_version }}` - - **Policy Decision Point**: `ghcr.io/${{ github.repository }}/policy-decision-point:${{ steps.version.outputs.full_version }}` - - **Consent Engine**: `ghcr.io/${{ github.repository }}/consent-engine:${{ steps.version.outputs.full_version }}` - - **Orchestration Engine**: `ghcr.io/${{ github.repository }}/orchestration-engine:${{ steps.version.outputs.full_version }}` + - **Portal Backend**: `${{ fromJSON(steps.meta-portal.outputs.json).tags[0] }}` + - **Audit Service**: `${{ fromJSON(steps.meta-audit.outputs.json).tags[0] }}` + - **Policy Decision Point**: `${{ fromJSON(steps.meta-pdp.outputs.json).tags[0] }}` + - **Consent Engine**: `${{ fromJSON(steps.meta-consent.outputs.json).tags[0] }}` + - **Orchestration Engine**: `${{ fromJSON(steps.meta-orchestration.outputs.json).tags[0] }}` + - **Admin Portal**: `${{ fromJSON(steps.meta-admin-portal.outputs.json).tags[0] }}` + - **Consent Portal**: `${{ fromJSON(steps.meta-consent-portal.outputs.json).tags[0] }}` + - **Member Portal**: `${{ fromJSON(steps.meta-member-portal.outputs.json).tags[0] }}` ### Pull Images ```bash - docker pull ghcr.io/${{ github.repository }}/portal-backend:${{ steps.version.outputs.full_version }} - docker pull ghcr.io/${{ github.repository }}/audit-service:${{ steps.version.outputs.full_version }} - docker pull ghcr.io/${{ github.repository }}/policy-decision-point:${{ steps.version.outputs.full_version }} - docker pull ghcr.io/${{ github.repository }}/consent-engine:${{ steps.version.outputs.full_version }} - docker pull ghcr.io/${{ github.repository }}/orchestration-engine:${{ steps.version.outputs.full_version }} + docker pull ${{ fromJSON(steps.meta-portal.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-audit.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-pdp.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-consent.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-orchestration.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-admin-portal.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-consent-portal.outputs.json).tags[0] }} + docker pull ${{ fromJSON(steps.meta-member-portal.outputs.json).tags[0] }} ``` draft: false prerelease: false + - name: Generate workflow summary + if: always() + run: | + echo "## Release Workflow Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** ${{ steps.dry-run.outputs.enabled == 'true' && '✅ Release' || '🧪 Dry Run' }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** ${{ env.GIT_COMMIT }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Built Images" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.dry-run.outputs.enabled }}" == "true" ]; then + echo "| Service | Image |" >> $GITHUB_STEP_SUMMARY + echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Portal Backend | \`${{ fromJSON(steps.meta-portal.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Audit Service | \`${{ fromJSON(steps.meta-audit.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Policy Decision Point | \`${{ fromJSON(steps.meta-pdp.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Consent Engine | \`${{ fromJSON(steps.meta-consent.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Orchestration Engine | \`${{ fromJSON(steps.meta-orchestration.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Admin Portal | \`${{ fromJSON(steps.meta-admin-portal.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Consent Portal | \`${{ fromJSON(steps.meta-consent-portal.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Member Portal | \`${{ fromJSON(steps.meta-member-portal.outputs.json).tags[0] }}\` |" >> $GITHUB_STEP_SUMMARY + else + echo "Images built but not pushed (dry run mode)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 00000000..4126cc22 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,43 @@ +# Release Guide + +Releases are automated via GitHub Actions when a semantic version tag is pushed (e.g., ``). + +## How to Release + +```bash +# 1. Checkout main +git checkout main && git pull + +# 2. Create & Push Tag +git tag +git push origin +``` + +This triggers the **Release** workflow which: +1. Builds Docker images for all **8 services** (Backend + Frontend). +2. Pushes tags: ``, ``, ``, `latest`, and `sha-`. +3. Scans images with Trivy. +4. Creates a GitHub Release with changelogs. + +## Manual Release +Go to **Actions** → **Release - Build and Publish All Services** → **Run workflow** → Enter version (e.g., ``). + +## Artifacts + +All images are published to **ghcr.io/opendif/opendif-core/**: + +| Category | Service | Image Name | +| :--- | :--- | :--- | +| **Backend** | Portal Backend | `portal-backend` | +| | Audit Service | `audit-service` | +| | Policy Decision Point | `policy-decision-point` | +| | Consent Engine | `consent-engine` | +| | Orchestration Engine | `orchestration-engine` | +| **Frontend** | Admin Portal | `admin-portal` | +| | Consent Portal | `consent-portal` | +| | Member Portal | `member-portal` | + +## Verification +```bash +docker pull ghcr.io/opendif/opendif-core/portal-backend: +``` diff --git a/exchange/consent-engine/go.mod b/exchange/consent-engine/go.mod index 1e04ab2d..f8bf328e 100644 --- a/exchange/consent-engine/go.mod +++ b/exchange/consent-engine/go.mod @@ -9,7 +9,6 @@ require ( ) require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/gov-dx-sandbox/exchange/shared/monitoring v0.0.0 github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 diff --git a/exchange/consent-engine/go.sum b/exchange/consent-engine/go.sum index ef70252f..3c2b526f 100644 --- a/exchange/consent-engine/go.sum +++ b/exchange/consent-engine/go.sum @@ -1,5 +1,3 @@ -github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= -github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -34,7 +32,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/exchange/consent-engine/v1/database/database_test.go b/exchange/consent-engine/v1/database/database_test.go new file mode 100644 index 00000000..1eea7cfa --- /dev/null +++ b/exchange/consent-engine/v1/database/database_test.go @@ -0,0 +1,81 @@ +package database + +import ( + "os" + "testing" + "time" + + "github.com/gov-dx-sandbox/exchange/consent-engine/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestNewDatabaseConfig(t *testing.T) { + // Clear environment variables + os.Unsetenv("DB_HOST") + os.Unsetenv("DB_PORT") + os.Unsetenv("DB_USERNAME") + os.Unsetenv("DB_PASSWORD") + os.Unsetenv("DB_NAME") + os.Unsetenv("DB_SSLMODE") + + dbConfigs := &config.DBConfigs{ + Host: "localhost", + Port: "5432", + Username: "postgres", + Password: "password", + Database: "testdb", + SSLMode: "prefer", + } + config := NewDatabaseConfig(dbConfigs) + + assert.Equal(t, "localhost", config.Host) + assert.Equal(t, "5432", config.Port) + assert.Equal(t, "postgres", config.Username) + assert.Equal(t, "password", config.Password) + assert.Equal(t, "testdb", config.Database) + assert.Equal(t, "prefer", config.SSLMode) + assert.Equal(t, 25, config.MaxOpenConns) + assert.Equal(t, 5, config.MaxIdleConns) + assert.Equal(t, time.Hour, config.ConnMaxLifetime) + assert.Equal(t, 30*time.Minute, config.ConnMaxIdleTime) + assert.Equal(t, 5, config.MaxRetries) +} + +func TestNewDatabaseConfig_WithEnvVars(t *testing.T) { + os.Setenv("DB_HOST", "test-host") + os.Setenv("DB_PORT", "5433") + os.Setenv("DB_USERNAME", "test-user") + os.Setenv("DB_PASSWORD", "test-password") + os.Setenv("DB_NAME", "test-db") + os.Setenv("DB_SSLMODE", "require") + defer func() { + os.Unsetenv("DB_HOST") + os.Unsetenv("DB_PORT") + os.Unsetenv("DB_USERNAME") + os.Unsetenv("DB_PASSWORD") + os.Unsetenv("DB_NAME") + os.Unsetenv("DB_SSLMODE") + }() + + dbConfigs := &config.DBConfigs{ + Host: "test-host", + Port: "5433", + Username: "test-user", + Password: "test-password", + Database: "test-db", + SSLMode: "require", + } + config := NewDatabaseConfig(dbConfigs) + + assert.Equal(t, "test-host", config.Host) + assert.Equal(t, "5433", config.Port) + assert.Equal(t, "test-user", config.Username) + assert.Equal(t, "test-password", config.Password) + assert.Equal(t, "test-db", config.Database) + assert.Equal(t, "require", config.SSLMode) +} + +// NOTE: Tests for ConnectGormDB with real database connections have been moved to +// tests/integration/database/database_test.go as integration tests. +// Unit tests should not use real database connections. + diff --git a/exchange/consent-engine/v1/handlers/internal_handler_test.go b/exchange/consent-engine/v1/handlers/internal_handler_test.go deleted file mode 100644 index 9ce0f768..00000000 --- a/exchange/consent-engine/v1/handlers/internal_handler_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "regexp" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/google/uuid" - "github.com/gov-dx-sandbox/exchange/consent-engine/v1/models" - "github.com/gov-dx-sandbox/exchange/consent-engine/v1/services" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func setupTestService(t *testing.T) (*services.ConsentService, sqlmock.Sqlmock) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - - dialector := postgres.New(postgres.Config{ - Conn: db, - DriverName: "postgres", - }) - - gormDB, err := gorm.Open(dialector, &gorm.Config{ - SkipDefaultTransaction: true, - }) - require.NoError(t, err) - - service, err := services.NewConsentService(gormDB, "http://localhost:5173") - require.NoError(t, err) - - return service, mock -} - -func TestInternalHandler_HealthCheck(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("GET", "/internal/api/v1/health", nil) - w := httptest.NewRecorder() - - handler.HealthCheck(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var response map[string]string - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - assert.Equal(t, "healthy", response["status"]) -} - -func TestInternalHandler_HealthCheck_MethodNotAllowed(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("POST", "/internal/api/v1/health", nil) - w := httptest.NewRecorder() - - handler.HealthCheck(w, req) - - assert.Equal(t, http.StatusMethodNotAllowed, w.Code) -} - -func TestInternalHandler_GetConsent_MissingAppId(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerId=user-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestInternalHandler_GetConsent_MissingOwner(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?appId=app-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestInternalHandler_CreateConsent_InvalidBody(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("POST", "/internal/api/v1/consents", bytes.NewBufferString("invalid json")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - handler.CreateConsent(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestInternalHandler_CreateConsent_MethodNotAllowed(t *testing.T) { - handler := &InternalHandler{consentService: nil} - - req := httptest.NewRequest("GET", "/internal/api/v1/consents", nil) - w := httptest.NewRecorder() - - handler.CreateConsent(w, req) - - assert.Equal(t, http.StatusMethodNotAllowed, w.Code) -} - -func TestInternalHandler_NewInternalHandler(t *testing.T) { - service, _ := setupTestService(t) - handler := NewInternalHandler(service) - assert.NotNil(t, handler) - assert.Equal(t, service, handler.consentService) -} - -func TestInternalHandler_GetConsent_Success_WithOwnerID(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - id := uuid.New() - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "owner_email", "app_id", "status", "type", "created_at", "updated_at", "grant_duration", "fields", "consent_portal_url"}). - AddRow(id, "user-1", "user@example.com", "app-1", "approved", "realtime", time.Now(), time.Now(), "P30D", "[]", "http://portal") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs("user-1", "app-1", 1). - WillReturnRows(rows) - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerId=user-1&appId=app-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var response models.ConsentResponseInternalView - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - assert.Equal(t, id.String(), response.ConsentID) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_GetConsent_Success_WithOwnerEmail(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - id := uuid.New() - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "owner_email", "app_id", "status", "type", "created_at", "updated_at", "grant_duration", "fields", "consent_portal_url"}). - AddRow(id, "user-1", "user@example.com", "app-1", "approved", "realtime", time.Now(), time.Now(), "P30D", "[]", "http://portal") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_email = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs("user@example.com", "app-1", 1). - WillReturnRows(rows) - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerEmail=user@example.com&appId=app-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_GetConsent_NotFound(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records"`)). - WillReturnError(gorm.ErrRecordNotFound) - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerId=user-1&appId=app-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_GetConsent_ContextTimeout(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) - defer cancel() - time.Sleep(2 * time.Nanosecond) // Ensure context is expired - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records"`)). - WillReturnError(ctx.Err()) - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerId=user-1&appId=app-1", nil) - req = req.WithContext(ctx) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusRequestTimeout, w.Code) -} - -func TestInternalHandler_CreateConsent_Success(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - // Mock GetConsentInternalView returning not found - specific query for owner_id and app_id - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(gorm.ErrRecordNotFound) - - // Mock Create - GORM Create doesn't use transactions for simple inserts - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnRows(sqlmock.NewRows([]string{"consent_id"}).AddRow(uuid.New())) - - reqBody := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user@example.com", - Fields: []models.ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}}, - }, - } - - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest("POST", "/internal/api/v1/consents", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - handler.CreateConsent(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_CreateConsent_CreateFailed(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - // Mock GetConsentInternalView returning not found - specific query - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(gorm.ErrRecordNotFound) - - // Mock Create failing - GORM Create doesn't use transactions for simple inserts - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnError(errors.New("db error")) - - reqBody := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user@example.com", - Fields: []models.ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}}, - }, - } - - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest("POST", "/internal/api/v1/consents", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - handler.CreateConsent(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_GetConsent_InternalError(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(errors.New("database connection error")) - - req := httptest.NewRequest("GET", "/internal/api/v1/consents?ownerId=user-1&appId=app-1", nil) - w := httptest.NewRecorder() - - handler.GetConsent(w, req) - - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestInternalHandler_CreateConsent_InternalError(t *testing.T) { - service, mock := setupTestService(t) - handler := NewInternalHandler(service) - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(gorm.ErrRecordNotFound) - - // Return error that's not ErrConsentCreateFailed to trigger internal error path - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnError(errors.New("unexpected database error")) - - reqBody := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user@example.com", - Fields: []models.ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}}, - }, - } - - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest("POST", "/internal/api/v1/consents", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - handler.CreateConsent(w, req) - - // The error gets wrapped as ErrConsentCreateFailed, so we get 400, not 500 - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NoError(t, mock.ExpectationsWereMet()) -} diff --git a/exchange/consent-engine/v1/handlers/portal_handler_test.go b/exchange/consent-engine/v1/handlers/portal_handler_test.go index cd7fa4a0..1c2fc4e8 100644 --- a/exchange/consent-engine/v1/handlers/portal_handler_test.go +++ b/exchange/consent-engine/v1/handlers/portal_handler_test.go @@ -15,11 +15,11 @@ import ( // setUserEmailInContext is a test helper to set user email in context // Uses the same context key as middleware.auth.go (userEmailKey = "userEmail") func setUserEmailInContext(ctx context.Context, email string) context.Context { - return context.WithValue(ctx, contextKey("userEmail"), email) + type contextKey string + const userEmailKey contextKey = "userEmail" + return context.WithValue(ctx, userEmailKey, email) } -type contextKey string - func TestPortalHandler_HealthCheck(t *testing.T) { handler := &PortalHandler{consentService: nil} diff --git a/exchange/consent-engine/v1/middleware/auth_test.go b/exchange/consent-engine/v1/middleware/auth_test.go new file mode 100644 index 00000000..87fbaa5f --- /dev/null +++ b/exchange/consent-engine/v1/middleware/auth_test.go @@ -0,0 +1,265 @@ +package middleware + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gov-dx-sandbox/exchange/consent-engine/v1/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestJWTVerifier(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string) *auth.JWTVerifier { + // Create a mock JWKS server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create JWKS response + nBytes := privateKey.PublicKey.N.Bytes() + eBytes := make([]byte, 4) + e := privateKey.PublicKey.E + for i := len(eBytes) - 1; i >= 0; i-- { + eBytes[i] = byte(e) + e >>= 8 + } + + jwks := map[string]interface{}{ + "keys": []map[string]interface{}{ + { + "kid": "test-key-id", + "kty": "RSA", + "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(nBytes), + "e": base64.RawURLEncoding.EncodeToString(eBytes), + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) + })) + t.Cleanup(server.Close) + + config := auth.JWTVerifierConfig{ + JWKSUrl: server.URL, + Issuer: issuer, + Audience: audience, + Organization: "test-org", + } + + verifier, err := auth.NewJWTVerifier(config) + require.NoError(t, err) + + // Wait for JWKS to be ready by attempting token verification with retry logic. + // The getPublicKey() method will automatically trigger a JWKS fetch if keys aren't loaded yet. + // This approach doesn't require any changes to jwt_verifier.go. + // Create a test token inline to verify JWKS is loaded + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "email": "test@example.com", + "org_name": "test-org", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + testToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + testToken.Header["kid"] = "test-key-id" + testTokenString, err := testToken.SignedString(privateKey) + require.NoError(t, err) + + maxRetries := 10 + retryDelay := 50 * time.Millisecond + + for i := 0; i < maxRetries; i++ { + _, err := verifier.VerifyToken(testTokenString) + if err == nil { + // JWKS is loaded and token verification succeeded + return verifier + } + // Check if error is due to missing keys (will trigger fetch) or other issues + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + + // Final attempt - if this fails, the test will fail with a clear error + _, err = verifier.VerifyToken(testTokenString) + require.NoError(t, err, "JWKS should be loaded and token should verify within timeout") + + return verifier +} + +func createTestToken(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience, email string) string { + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "email": email, + "org_name": "test-org", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "test-key-id" + + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + return tokenString +} + +func TestNewJWTAuthMiddleware(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + assert.NotNil(t, middleware) + assert.Equal(t, verifier, middleware.verifier) +} + +func TestJWTAuthMiddleware_Authenticate_Success(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + token := createTestToken(t, privateKey, "test-issuer", "test-audience", "user@example.com") + + req := httptest.NewRequest("GET", "/api/v1/consents/123", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + email, ok := GetUserEmailFromContext(r.Context()) + assert.True(t, ok) + assert.Equal(t, "user@example.com", email) + }) + + middleware.Authenticate(next).ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestJWTAuthMiddleware_Authenticate_MissingHeader(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + req := httptest.NewRequest("GET", "/api/v1/consents/123", nil) + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware.Authenticate(next).ServeHTTP(w, req) + + assert.False(t, nextCalled) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestJWTAuthMiddleware_Authenticate_InvalidFormat(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + req := httptest.NewRequest("GET", "/api/v1/consents/123", nil) + req.Header.Set("Authorization", "InvalidFormat token") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware.Authenticate(next).ServeHTTP(w, req) + + assert.False(t, nextCalled) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestJWTAuthMiddleware_Authenticate_EmptyToken(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + req := httptest.NewRequest("GET", "/api/v1/consents/123", nil) + req.Header.Set("Authorization", "Bearer ") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware.Authenticate(next).ServeHTTP(w, req) + + assert.False(t, nextCalled) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestJWTAuthMiddleware_Authenticate_InvalidToken(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + verifier := createTestJWTVerifier(t, privateKey, "test-issuer", "test-audience") + middleware := NewJWTAuthMiddleware(verifier) + + req := httptest.NewRequest("GET", "/api/v1/consents/123", nil) + req.Header.Set("Authorization", "Bearer invalid.token.here") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware.Authenticate(next).ServeHTTP(w, req) + + assert.False(t, nextCalled) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGetUserEmailFromContext(t *testing.T) { + ctx := context.WithValue(context.Background(), userEmailKey, "user@example.com") + + email, ok := GetUserEmailFromContext(ctx) + assert.True(t, ok) + assert.Equal(t, "user@example.com", email) +} + +func TestGetUserEmailFromContext_NotFound(t *testing.T) { + ctx := context.Background() + + email, ok := GetUserEmailFromContext(ctx) + assert.False(t, ok) + assert.Empty(t, email) +} + +func TestGetUserEmailFromContext_WrongType(t *testing.T) { + ctx := context.WithValue(context.Background(), userEmailKey, 123) + + email, ok := GetUserEmailFromContext(ctx) + assert.False(t, ok) + assert.Empty(t, email) +} + diff --git a/exchange/consent-engine/v1/middleware/cors_test.go b/exchange/consent-engine/v1/middleware/cors_test.go new file mode 100644 index 00000000..bcb27b38 --- /dev/null +++ b/exchange/consent-engine/v1/middleware/cors_test.go @@ -0,0 +1,171 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultCORSConfig(t *testing.T) { + // Clear environment variable + os.Unsetenv("CORS_ALLOWED_ORIGINS") + + config := DefaultCORSConfig("http://localhost:5173") + + assert.Contains(t, config.AllowedOrigins, "http://localhost:5173") + assert.Contains(t, config.AllowedMethods, "GET") + assert.Contains(t, config.AllowedMethods, "POST") + assert.Contains(t, config.AllowedHeaders, "Authorization") + assert.Contains(t, config.AllowedHeaders, "Content-Type") + assert.True(t, config.AllowCredentials) + assert.Equal(t, 86400, config.MaxAge) +} + +func TestDefaultCORSConfig_WithEnvVar(t *testing.T) { + // Test that DefaultCORSConfig correctly parses comma-separated origins + // This simulates how the config package combines CORS_ALLOWED_ORIGINS with consent portal URL + config := DefaultCORSConfig("https://example.com,https://test.com,http://localhost:5173") + + assert.Contains(t, config.AllowedOrigins, "http://localhost:5173") + assert.Contains(t, config.AllowedOrigins, "https://example.com") + assert.Contains(t, config.AllowedOrigins, "https://test.com") +} + +func TestCORSMiddleware_AllowedOrigin(t *testing.T) { + config := CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowCredentials: true, + MaxAge: 3600, + } + + middleware := CORSMiddleware(config) + + req := httptest.NewRequest("GET", "/api/v1/consents", nil) + req.Header.Set("Origin", "https://example.com") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware(next).ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Equal(t, "https://example.com", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET, POST", w.Header().Get("Access-Control-Allow-Methods")) + assert.Equal(t, "Content-Type, Authorization", w.Header().Get("Access-Control-Allow-Headers")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "3600", w.Header().Get("Access-Control-Max-Age")) + assert.Contains(t, w.Header().Values("Vary"), "Origin") +} + +func TestCORSMiddleware_DisallowedOrigin(t *testing.T) { + config := CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + } + + middleware := CORSMiddleware(config) + + req := httptest.NewRequest("GET", "/api/v1/consents", nil) + req.Header.Set("Origin", "https://malicious.com") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware(next).ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSMiddleware_WildcardOrigin(t *testing.T) { + config := CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowCredentials: false, // Must be false with wildcard + } + + middleware := CORSMiddleware(config) + + req := httptest.NewRequest("GET", "/api/v1/consents", nil) + req.Header.Set("Origin", "https://any-origin.com") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware(next).ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSMiddleware_PreflightRequest(t *testing.T) { + config := CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedHeaders: []string{"Content-Type"}, + } + + middleware := CORSMiddleware(config) + + req := httptest.NewRequest("OPTIONS", "/api/v1/consents", nil) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware(next).ServeHTTP(w, req) + + assert.False(t, nextCalled) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Values("Vary"), "Access-Control-Request-Method") + assert.Contains(t, w.Header().Values("Vary"), "Access-Control-Request-Headers") +} + +func TestCORSMiddleware_WildcardWithCredentials_Panic(t *testing.T) { + config := CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, // This should cause a panic + } + + assert.Panics(t, func() { + CORSMiddleware(config) + }) +} + +func TestNewCORSMiddleware(t *testing.T) { + os.Unsetenv("CORS_ALLOWED_ORIGINS") + + middleware := NewCORSMiddleware("http://localhost:5173") + assert.NotNil(t, middleware) + + req := httptest.NewRequest("GET", "/api/v1/consents", nil) + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + + middleware(next).ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Equal(t, "http://localhost:5173", w.Header().Get("Access-Control-Allow-Origin")) +} diff --git a/exchange/consent-engine/v1/models/consent_test.go b/exchange/consent-engine/v1/models/consent_test.go new file mode 100644 index 00000000..a47ef05e --- /dev/null +++ b/exchange/consent-engine/v1/models/consent_test.go @@ -0,0 +1,194 @@ +package models + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestConsentField_Equals(t *testing.T) { + field1 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + Owner: OwnerCitizen, + } + + field2 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + Owner: OwnerCitizen, + } + + assert.True(t, field1.Equals(field2)) +} + +func TestConsentField_Equals_DifferentFieldName(t *testing.T) { + field1 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + Owner: OwnerCitizen, + } + + field2 := ConsentField{ + FieldName: "name", + SchemaID: "schema-1", + Owner: OwnerCitizen, + } + + assert.False(t, field1.Equals(field2)) +} + +func TestConsentField_Equals_WithDisplayName(t *testing.T) { + displayName := "Email Address" + field1 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + DisplayName: &displayName, + Owner: OwnerCitizen, + } + + field2 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + DisplayName: &displayName, + Owner: OwnerCitizen, + } + + assert.True(t, field1.Equals(field2)) +} + +func TestConsentField_Equals_OneWithDisplayName(t *testing.T) { + displayName := "Email Address" + field1 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + DisplayName: &displayName, + Owner: OwnerCitizen, + } + + field2 := ConsentField{ + FieldName: "email", + SchemaID: "schema-1", + Owner: OwnerCitizen, + } + + assert.False(t, field1.Equals(field2)) +} + +func TestConsentRecord_TableName(t *testing.T) { + record := ConsentRecord{} + assert.Equal(t, "consent_records", record.TableName()) +} + +func TestConsentRecord_ToConsentResponseInternalView_Pending(t *testing.T) { + portalURL := "http://portal.example.com/consent/123" + record := ConsentRecord{ + ConsentID: uuid.New(), + Status: string(StatusPending), + ConsentPortalURL: portalURL, + Fields: []ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: OwnerCitizen}}, + } + + response := record.ToConsentResponseInternalView() + + assert.Equal(t, record.ConsentID.String(), response.ConsentID) + assert.Equal(t, string(StatusPending), response.Status) + assert.NotNil(t, response.ConsentPortalURL) + assert.Equal(t, portalURL, *response.ConsentPortalURL) + assert.NotNil(t, response.Fields) + assert.Equal(t, 1, len(*response.Fields)) +} + +func TestConsentRecord_ToConsentResponseInternalView_Approved(t *testing.T) { + record := ConsentRecord{ + ConsentID: uuid.New(), + Status: string(StatusApproved), + Fields: []ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: OwnerCitizen}}, + } + + response := record.ToConsentResponseInternalView() + + assert.Equal(t, record.ConsentID.String(), response.ConsentID) + assert.Equal(t, string(StatusApproved), response.Status) + assert.Nil(t, response.ConsentPortalURL) // Not pending, so no portal URL + assert.NotNil(t, response.Fields) // Approved status includes fields +} + +func TestConsentRecord_ToConsentResponseInternalView_Rejected(t *testing.T) { + record := ConsentRecord{ + ConsentID: uuid.New(), + Status: string(StatusRejected), + Fields: []ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: OwnerCitizen}}, + } + + response := record.ToConsentResponseInternalView() + + assert.Equal(t, record.ConsentID.String(), response.ConsentID) + assert.Equal(t, string(StatusRejected), response.Status) + assert.Nil(t, response.ConsentPortalURL) + assert.Nil(t, response.Fields) // Rejected status doesn't include fields +} + +func TestConsentRecord_ToConsentResponseInternalView_PendingNoURL(t *testing.T) { + record := ConsentRecord{ + ConsentID: uuid.New(), + Status: string(StatusPending), + ConsentPortalURL: "", // Empty URL + Fields: []ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: OwnerCitizen}}, + } + + response := record.ToConsentResponseInternalView() + + assert.Nil(t, response.ConsentPortalURL) // Empty URL should not be included +} + +func TestConsentRecord_ToConsentResponsePortalView(t *testing.T) { + appName := "Test App" + record := ConsentRecord{ + ConsentID: uuid.New(), + AppID: "app-123", + AppName: &appName, + OwnerID: "owner-123", + OwnerEmail: "owner@example.com", + Status: string(StatusApproved), + Type: string(TypeRealtime), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Fields: []ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: OwnerCitizen}}, + } + + response := record.ToConsentResponsePortalView() + + assert.Equal(t, record.AppID, response.AppID) + assert.NotNil(t, response.AppName) + assert.Equal(t, appName, *response.AppName) + assert.Equal(t, record.OwnerID, response.OwnerID) + assert.Equal(t, record.OwnerEmail, response.OwnerEmail) + assert.Equal(t, ConsentStatus(record.Status), response.Status) + assert.Equal(t, ConsentType(record.Type), response.Type) + assert.Equal(t, record.CreatedAt, response.CreatedAt) + assert.Equal(t, record.UpdatedAt, response.UpdatedAt) + assert.Equal(t, 1, len(response.Fields)) +} + +func TestConsentRecord_ToConsentResponsePortalView_NoAppName(t *testing.T) { + record := ConsentRecord{ + ConsentID: uuid.New(), + AppID: "app-123", + AppName: nil, + OwnerID: "owner-123", + OwnerEmail: "owner@example.com", + Status: string(StatusApproved), + Type: string(TypeRealtime), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Fields: []ConsentField{}, + } + + response := record.ToConsentResponsePortalView() + + assert.Nil(t, response.AppName) +} + diff --git a/exchange/consent-engine/v1/router/router_test.go b/exchange/consent-engine/v1/router/router_test.go new file mode 100644 index 00000000..569b90da --- /dev/null +++ b/exchange/consent-engine/v1/router/router_test.go @@ -0,0 +1,105 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gov-dx-sandbox/exchange/consent-engine/v1/auth" + "github.com/gov-dx-sandbox/exchange/consent-engine/v1/handlers" + "github.com/stretchr/testify/assert" +) + +func TestNewV1Router(t *testing.T) { + internalHandler := &handlers.InternalHandler{} + portalHandler := &handlers.PortalHandler{} + + // Create a minimal JWT verifier config for testing + config := auth.JWTVerifierConfig{ + JWKSUrl: "http://localhost/.well-known/jwks.json", + Issuer: "test-issuer", + Audience: "test-audience", + } + jwtVerifier, err := auth.NewJWTVerifier(config) + if err != nil { + // Skip test if JWT verifier creation fails (e.g., network issue) + t.Skipf("Failed to create JWT verifier: %v", err) + } + + router := NewV1Router("http://localhost:5173", internalHandler, portalHandler, jwtVerifier) + + assert.NotNil(t, router) + assert.Equal(t, internalHandler, router.internalHandler) + assert.Equal(t, portalHandler, router.portalHandler) + assert.NotNil(t, router.authMiddleware) + assert.NotNil(t, router.corsMiddleware) +} + +func TestV1Router_RegisterRoutes(t *testing.T) { + internalHandler := &handlers.InternalHandler{} + portalHandler := &handlers.PortalHandler{} + + config := auth.JWTVerifierConfig{ + JWKSUrl: "http://localhost/.well-known/jwks.json", + Issuer: "test-issuer", + Audience: "test-audience", + } + jwtVerifier, err := auth.NewJWTVerifier(config) + if err != nil { + t.Skipf("Failed to create JWT verifier: %v", err) + } + + router := NewV1Router("http://localhost:5173", internalHandler, portalHandler, jwtVerifier) + mux := http.NewServeMux() + + router.RegisterRoutes(mux) + + // Test that internal routes are registered + req := httptest.NewRequest("GET", "/internal/api/v1/health", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + // Should not return 404 (route exists) + assert.NotEqual(t, http.StatusNotFound, w.Code) + + // Test that portal routes are registered + req = httptest.NewRequest("GET", "/api/v1/health", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + // Should not return 404 (route exists) + assert.NotEqual(t, http.StatusNotFound, w.Code) +} + +func TestV1Router_ApplyCORS(t *testing.T) { + internalHandler := &handlers.InternalHandler{} + portalHandler := &handlers.PortalHandler{} + + config := auth.JWTVerifierConfig{ + JWKSUrl: "http://localhost/.well-known/jwks.json", + Issuer: "test-issuer", + Audience: "test-audience", + } + jwtVerifier, err := auth.NewJWTVerifier(config) + if err != nil { + t.Skipf("Failed to create JWT verifier: %v", err) + } + + router := NewV1Router("http://localhost:5173", internalHandler, portalHandler, jwtVerifier) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := router.ApplyCORS(handler) + + assert.NotNil(t, wrapped) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + + wrapped.ServeHTTP(w, req) + + // CORS headers should be set + assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Origin")) +} + diff --git a/exchange/consent-engine/v1/services/consent_service_test.go b/exchange/consent-engine/v1/services/consent_service_test.go deleted file mode 100644 index b167b49a..00000000 --- a/exchange/consent-engine/v1/services/consent_service_test.go +++ /dev/null @@ -1,676 +0,0 @@ -package services - -import ( - "context" - "regexp" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/google/uuid" - "github.com/gov-dx-sandbox/exchange/consent-engine/v1/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - - dialector := postgres.New(postgres.Config{ - Conn: db, - DriverName: "postgres", - }) - - gormDB, err := gorm.Open(dialector, &gorm.Config{ - SkipDefaultTransaction: true, - }) - require.NoError(t, err) - - return gormDB, mock -} - -func TestNewConsentService(t *testing.T) { - db, _ := setupMockDB(t) - - tests := []struct { - name string - consentPortalBaseURL string - expectError bool - }{ - { - name: "Valid URL", - consentPortalBaseURL: "http://localhost:5173", - expectError: false, - }, - { - name: "Invalid URL - Empty", - consentPortalBaseURL: "", - expectError: true, - }, - { - name: "Invalid URL - No Scheme", - consentPortalBaseURL: "localhost:5173", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - service, err := NewConsentService(db, tt.consentPortalBaseURL) - if tt.expectError { - assert.Error(t, err) - assert.Nil(t, service) - } else { - assert.NoError(t, err) - assert.NotNil(t, service) - } - }) - } -} - -func TestGetConsentInternalView_ByID(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "status", "created_at", "updated_at", "grant_duration"}). - AddRow(id, "user-1", "approved", time.Now(), time.Now(), "P30D") - - mock.ExpectQuery(`SELECT \* FROM "consent_records" WHERE consent_id = \$1 ORDER BY created_at DESC.* LIMIT \$2`). - WithArgs(id, 1). // GORM uses numeric args for postgres - WillReturnRows(rows) - - resp, err := service.GetConsentInternalView(ctx, &idStr, nil, nil, nil) - require.NoError(t, err) - assert.Equal(t, idStr, resp.ConsentID) -} - -func TestUpdateConsentStatusByPortalAction(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - updatedBy := "user-action" - - req := models.ConsentPortalActionRequest{ - ConsentID: idStr, - Action: models.ActionApprove, - UpdatedBy: updatedBy, - } - - // Mock finding the record - rows := sqlmock.NewRows([]string{"consent_id", "status", "grant_duration"}). - AddRow(id, "pending", "P30D") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1 ORDER BY "consent_records"."consent_id" LIMIT $2`)). - WithArgs(id, 1). - WillReturnRows(rows) - - // Mock updating the record - // GORM updates can be complex, often inside transaction or save - // Expect UPDATE - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - err := service.UpdateConsentStatusByPortalAction(ctx, req) - require.NoError(t, err) - - // Verify expectations - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestCreateConsentRecord_New(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - consentType := models.TypeRealtime - grantDuration := string(models.DurationOneDay) - - req := models.CreateConsentRequest{ - AppID: "app-1", - AppName: nil, - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user-1@example.com", - Fields: []models.ConsentField{ - {FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}, - }, - }, - ConsentType: &consentType, - GrantDuration: &grantDuration, - } - - // Mock GetConsentInternalView returning RecordNotFound - // Query: SELECT * FROM "consent_records" WHERE owner_id = ? AND app_id = ? ORDER BY created_at DESC LIMIT 1 - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(gorm.ErrRecordNotFound) - - // Mock Create - // mock.ExpectBegin() // SkipDefaultTransaction is true - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnRows(sqlmock.NewRows([]string{"consent_id"}).AddRow(uuid.New())) - // mock.ExpectCommit() - - resp, err := service.CreateConsentRecord(ctx, req) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, string(models.StatusPending), resp.Status) - - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestCreateConsentRecord_ExistingMatch(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - fields := []models.ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}} - req := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user-1@example.com", - Fields: fields, - }, - } - - // Mock GetConsentInternalView returning existing Pending consent - rows := sqlmock.NewRows([]string{"consent_id", "status", "fields"}). - AddRow(uuid.New(), "pending", `[{"fieldName":"email","schemaId":"schema-1","owner":"citizen"}]`) // JSONB match - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs("user-1", "app-1", 1). - WillReturnRows(rows) - - resp, err := service.CreateConsentRecord(ctx, req) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, string(models.StatusPending), resp.Status) - - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestCreateConsentRecord_RevokeAndCreate(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - // New fields differ from stored fields - req := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user-1@example.com", - Fields: []models.ConsentField{{FieldName: "new_field", SchemaID: "schema-1", Owner: "citizen"}}, - }, - } - - existID := uuid.New() - - // Mock GetConsentInternalView returning existing Approved consent with old fields - rows := sqlmock.NewRows([]string{"consent_id", "status", "fields"}). - AddRow(existID, "approved", `[{"fieldName":"old_field","schemaId":"schema-1","owner":"citizen"}]`) - - // Note: GORM select - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs("user-1", "app-1", 1). - WillReturnRows(rows) - - // Expect Transaction Begin (RevokeAndCreate uses transaction) - mock.ExpectBegin() - - // Revoke: Find Existing to Revoke - rowsExist := sqlmock.NewRows([]string{"consent_id", "status"}). - AddRow(existID, "approved") - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)). - WithArgs(existID, 1). // LIMIT 1 - WillReturnRows(rowsExist) - - // Revoke: Update Status - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - // Create: Insert New - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnRows(sqlmock.NewRows([]string{"consent_id"}).AddRow(uuid.New())) - - mock.ExpectCommit() - - resp, err := service.CreateConsentRecord(ctx, req) - require.NoError(t, err) - assert.NotNil(t, resp) - - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetConsentInternalView_ByOwnerApp(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - ownerID := "user-1" - appID := "app-1" - id := uuid.New() - - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "status", "created_at", "updated_at", "grant_duration"}). - AddRow(id, "user-1", "approved", time.Now(), time.Now(), "P30D") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs(ownerID, appID, 1). // LIMIT 1 - WillReturnRows(rows) - - resp, err := service.GetConsentInternalView(ctx, nil, &ownerID, nil, &appID) - require.NoError(t, err) - assert.Equal(t, id.String(), resp.ConsentID) -} - -func TestGetConsentInternalView_ExpiredPending(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - - // Pending expired - expiredTime := time.Now().Add(-2 * time.Hour) - rows := sqlmock.NewRows([]string{"consent_id", "status", "pending_expires_at"}). - AddRow(id, "pending", expiredTime) - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)). - WithArgs(id, 1). // LIMIT 1 - WillReturnRows(rows) - - // Expect Update to Expired - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - resp, err := service.GetConsentInternalView(ctx, &idStr, nil, nil, nil) - require.NoError(t, err) - assert.Equal(t, string(models.StatusExpired), resp.Status) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestCreateConsentRecord_InvalidInput(t *testing.T) { - db, _ := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - // Missing AppID - req := models.CreateConsentRequest{ - AppID: "", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user-1@example.com", - Fields: []models.ConsentField{{FieldName: "email"}}, - }, - } - - _, err := service.CreateConsentRecord(ctx, req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "appId is required") -} - -func TestGetConsentInternalView_ExpiredApproved(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - - // Approved expired - expiredTime := time.Now().Add(-2 * time.Hour) - rows := sqlmock.NewRows([]string{"consent_id", "status", "grant_expires_at"}). - AddRow(id, "approved", expiredTime) - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)). - WithArgs(id, 1). // LIMIT 1 - WillReturnRows(rows) - - // Expect Update to Expired - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - resp, err := service.GetConsentInternalView(ctx, &idStr, nil, nil, nil) - require.NoError(t, err) - assert.Equal(t, string(models.StatusExpired), resp.Status) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestCreateConsentRecord_DurationsAndTypes(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - durations := []models.GrantDuration{ - models.DurationOneHour, - models.DurationSixHours, - models.DurationTwelveHours, - models.DurationSevenDays, - models.DurationThirtyDays, - } - - for _, dur := range durations { - d := string(dur) - ct := models.TypeOffline - req := models.CreateConsentRequest{ - AppID: "app-test", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user-1", - Fields: []models.ConsentField{{FieldName: "f", SchemaID: "s", Owner: "citizen"}}, - }, - ConsentType: &ct, - GrantDuration: &d, - } - - // Mock Get Not Found - specific query for owner_id and app_id - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs(req.ConsentRequirement.OwnerID, req.AppID, 1). - WillReturnError(gorm.ErrRecordNotFound) - - // Mock Insert - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnRows(sqlmock.NewRows([]string{"consent_id"}).AddRow(uuid.New())) - - _, err := service.CreateConsentRecord(ctx, req) - require.NoError(t, err) - } - - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestRevokeConsent(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - revokedBy := "user-revoke" - - // Mock Transaction - mock.ExpectBegin() - - // Find record - rows := sqlmock.NewRows([]string{"consent_id", "status"}). - AddRow(id, "approved") - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1 ORDER BY "consent_records"."consent_id" LIMIT $2`)). - WithArgs(id, 1). - WillReturnRows(rows) - - // Save (Update) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - mock.ExpectCommit() - - err := service.RevokeConsent(ctx, idStr, revokedBy) - require.NoError(t, err) - - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetConsentInternalView_ByOwnerEmail(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - ownerEmail := "user@example.com" - appID := "app-1" - id := uuid.New() - - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "status", "created_at", "updated_at", "grant_duration"}). - AddRow(id, "user-1", "approved", time.Now(), time.Now(), "P30D") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_email = $1 AND app_id = $2 ORDER BY created_at DESC`)). - WithArgs(ownerEmail, appID, 1). // LIMIT 1 - WillReturnRows(rows) - - resp, err := service.GetConsentInternalView(ctx, nil, nil, &ownerEmail, &appID) - require.NoError(t, err) - assert.Equal(t, id.String(), resp.ConsentID) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetConsentInternalView_DBError(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - idStr := id.String() - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $2`)). - WithArgs(id, 1). - WillReturnError(gorm.ErrInvalidDB) - - _, err := service.GetConsentInternalView(ctx, &idStr, nil, nil, nil) - assert.Error(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestUpdateConsentStatusByPortalAction_InvalidAction(t *testing.T) { - db, _ := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - req := models.ConsentPortalActionRequest{ - Action: "INVALID", - } - - err := service.UpdateConsentStatusByPortalAction(ctx, req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid action") -} - -func TestUpdateConsentStatusByPortalAction_InvalidUUID(t *testing.T) { - db, _ := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - req := models.ConsentPortalActionRequest{ - ConsentID: "invalid-uuid", - Action: models.ActionApprove, - UpdatedBy: "user@example.com", - } - - err := service.UpdateConsentStatusByPortalAction(ctx, req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid consent ID") -} - -func TestUpdateConsentStatusByPortalAction_NotFound(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - req := models.ConsentPortalActionRequest{ - ConsentID: id.String(), - Action: models.ActionApprove, - UpdatedBy: "user@example.com", - } - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnError(gorm.ErrRecordNotFound) - - err := service.UpdateConsentStatusByPortalAction(ctx, req) - assert.Error(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestUpdateConsentStatusByPortalAction_Reject(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "status", "grant_duration"}). - AddRow(id, "user-1", "pending", "P30D") - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnRows(rows) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "consent_records"`)). - WillReturnResult(sqlmock.NewResult(0, 1)) - - req := models.ConsentPortalActionRequest{ - ConsentID: id.String(), - Action: models.ActionReject, - UpdatedBy: "user@example.com", - } - - err := service.UpdateConsentStatusByPortalAction(ctx, req) - assert.NoError(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestRevokeConsent_InvalidUUID(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - // UUID parsing happens inside transaction, so transaction begins then errors - mock.ExpectBegin() - mock.ExpectRollback() - - err := service.RevokeConsent(ctx, "invalid-uuid", "user@example.com") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid consent ID") - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestRevokeConsent_NotFound(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnError(gorm.ErrRecordNotFound) - mock.ExpectRollback() - - err := service.RevokeConsent(ctx, id.String(), "user@example.com") - assert.Error(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestRevokeConsent_WrongStatus(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - rows := sqlmock.NewRows([]string{"consent_id", "status"}). - AddRow(id, "rejected") - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnRows(rows) - mock.ExpectRollback() - - err := service.RevokeConsent(ctx, id.String(), "user@example.com") - assert.Error(t, err) - assert.Contains(t, err.Error(), "only approved or pending consents can be revoked") - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetConsentPortalView_Success(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - rows := sqlmock.NewRows([]string{"consent_id", "owner_id", "owner_email", "app_id", "status", "type", "created_at", "updated_at", "grant_duration", "fields"}). - AddRow(id, "user-1", "user@example.com", "app-1", "approved", "realtime", time.Now(), time.Now(), "P30D", "[]") - - // GORM First() adds LIMIT 1 - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnRows(rows) - - resp, err := service.GetConsentPortalView(ctx, id.String()) - require.NoError(t, err) - assert.Equal(t, "user-1", resp.OwnerID) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetConsentPortalView_InvalidUUID(t *testing.T) { - db, _ := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - _, err := service.GetConsentPortalView(ctx, "invalid-uuid") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid consent ID") -} - -func TestGetConsentPortalView_NotFound(t *testing.T) { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - id := uuid.New() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE consent_id = $1`)+".*"+regexp.QuoteMeta(`LIMIT $2`)). - WithArgs(id, 1). - WillReturnError(gorm.ErrRecordNotFound) - - _, err := service.GetConsentPortalView(ctx, id.String()) - assert.Error(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) -} - -func TestParseGrantDuration_AllCases(t *testing.T) { - // Test valid grant durations only (invalid ones are rejected by validation) - validDurations := []models.GrantDuration{ - models.DurationOneHour, - models.DurationSixHours, - models.DurationTwelveHours, - models.DurationOneDay, - models.DurationSevenDays, - models.DurationThirtyDays, - } - - for _, dur := range validDurations { - db, mock := setupMockDB(t) - service, _ := NewConsentService(db, "http://portal") - ctx := context.Background() - - grantDuration := string(dur) - req := models.CreateConsentRequest{ - AppID: "app-1", - ConsentRequirement: models.ConsentRequirement{ - OwnerID: "user-1", - OwnerEmail: "user@example.com", - Fields: []models.ConsentField{{FieldName: "email", SchemaID: "schema-1", Owner: "citizen"}}, - }, - GrantDuration: &grantDuration, - } - - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "consent_records" WHERE owner_id = $1 AND app_id = $2 ORDER BY created_at DESC`)+".*"+regexp.QuoteMeta(` LIMIT $3`)). - WithArgs("user-1", "app-1", 1). - WillReturnError(gorm.ErrRecordNotFound) - - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "consent_records"`)). - WillReturnRows(sqlmock.NewRows([]string{"consent_id"}).AddRow(uuid.New())) - - _, err := service.CreateConsentRecord(ctx, req) - assert.NoError(t, err) - assert.NoError(t, mock.ExpectationsWereMet()) - } -} diff --git a/exchange/consent-engine/v1/utils/http_response_test.go b/exchange/consent-engine/v1/utils/http_response_test.go new file mode 100644 index 00000000..c3c0d747 --- /dev/null +++ b/exchange/consent-engine/v1/utils/http_response_test.go @@ -0,0 +1,132 @@ +package utils + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gov-dx-sandbox/exchange/consent-engine/v1/models" + "github.com/stretchr/testify/assert" +) + +func TestRespondWithJSON(t *testing.T) { + w := httptest.NewRecorder() + payload := map[string]string{"status": "healthy"} + + RespondWithJSON(w, http.StatusOK, payload) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "healthy", response["status"]) +} + +func TestRespondWithJSON_ComplexPayload(t *testing.T) { + w := httptest.NewRecorder() + payload := map[string]interface{}{ + "id": "123", + "name": "test", + "items": []string{"a", "b", "c"}, + } + + RespondWithJSON(w, http.StatusCreated, payload) + + assert.Equal(t, http.StatusCreated, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "123", response["id"]) + assert.Equal(t, "test", response["name"]) +} + +func TestRespondWithError(t *testing.T) { + w := httptest.NewRecorder() + + RespondWithError(w, http.StatusBadRequest, models.ErrorCodeBadRequest, "Invalid request") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var response ErrorResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, string(models.ErrorCodeBadRequest), response.Error.Code) + assert.Equal(t, "Invalid request", response.Error.Message) +} + +func TestRespondWithError_Unauthorized(t *testing.T) { + w := httptest.NewRecorder() + + RespondWithError(w, http.StatusUnauthorized, models.ErrorCodeUnauthorized, "Token expired") + + assert.Equal(t, http.StatusUnauthorized, w.Code) + + var response ErrorResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, string(models.ErrorCodeUnauthorized), response.Error.Code) + assert.Equal(t, "Token expired", response.Error.Message) +} + +func TestRespondWithError_InternalError(t *testing.T) { + w := httptest.NewRecorder() + + RespondWithError(w, http.StatusInternalServerError, models.ErrorCodeInternalError, "Database connection failed") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + + var response ErrorResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, string(models.ErrorCodeInternalError), response.Error.Code) + assert.Equal(t, "Database connection failed", response.Error.Message) +} + +func TestRespondWithError_Forbidden(t *testing.T) { + w := httptest.NewRecorder() + + RespondWithError(w, http.StatusForbidden, models.ErrorCodeForbidden, "Access denied") + + assert.Equal(t, http.StatusForbidden, w.Code) + + var response ErrorResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, string(models.ErrorCodeForbidden), response.Error.Code) + assert.Equal(t, "Access denied", response.Error.Message) +} + +func TestRespondWithError_NotFound(t *testing.T) { + w := httptest.NewRecorder() + + RespondWithError(w, http.StatusNotFound, models.ErrorCodeConsentNotFound, "Consent not found") + + assert.Equal(t, http.StatusNotFound, w.Code) + + var response ErrorResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, string(models.ErrorCodeConsentNotFound), response.Error.Code) + assert.Equal(t, "Consent not found", response.Error.Message) +} + +// TestRespondWithJSON_EncodingError tests the error path when JSON encoding fails +// This tests the error handling in RespondWithJSON when json.Encode returns an error +func TestRespondWithJSON_EncodingError(t *testing.T) { + w := httptest.NewRecorder() + + // Use a channel as payload - channels cannot be JSON encoded, which will cause an error + ch := make(chan int) + RespondWithJSON(w, http.StatusOK, ch) + + // Status code should still be set even if encoding fails + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + // Body should be empty or contain error indication + // The function logs the error but doesn't write to response body after headers are written +} diff --git a/exchange/policy-decision-point/v1/database_test.go b/exchange/policy-decision-point/v1/database_test.go index 90096e55..7e5a2465 100644 --- a/exchange/policy-decision-point/v1/database_test.go +++ b/exchange/policy-decision-point/v1/database_test.go @@ -52,8 +52,12 @@ func TestNewDatabaseConfig_WithConfig(t *testing.T) { assert.Equal(t, "disable", dbConfig.SSLMode) } +// TestConnectGormDB_WithSQLite tests connection pool configuration using SQLite in-memory database. +// This is a unit test that uses SQLite in-memory (not a real PostgreSQL connection) to test +// connection pool configuration logic without requiring a real database. func TestConnectGormDB_WithSQLite(t *testing.T) { - // Use SQLite for testing instead of PostgreSQL + // Use SQLite in-memory for testing instead of PostgreSQL + // This is acceptable for unit tests as it's not a real network connection db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) defer func() { @@ -90,17 +94,6 @@ func TestConnectGormDB_WithSQLite(t *testing.T) { assert.NoError(t, err) } -func TestConnectGormDB_InvalidConnection(t *testing.T) { - config := &DatabaseConfig{ - Host: "invalid-host", - Port: "5432", - Username: "invalid-user", - Password: "invalid-password", - Database: "invalid-db", - SSLMode: "disable", - } - - _, err := ConnectGormDB(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect") -} +// NOTE: Tests for ConnectGormDB with real database connections have been moved to +// tests/integration/database/pdp_database_test.go as integration tests. +// Unit tests should not use real database connections. diff --git a/exchange/policy-decision-point/v1/handler.go b/exchange/policy-decision-point/v1/handler.go index c6989bb6..2fb1e102 100644 --- a/exchange/policy-decision-point/v1/handler.go +++ b/exchange/policy-decision-point/v1/handler.go @@ -75,6 +75,12 @@ func (h *Handler) CreatePolicyMetadata(w http.ResponseWriter, r *http.Request) { return } + // Validate required fields + if strings.TrimSpace(req.SchemaID) == "" { + utils.RespondWithError(w, http.StatusBadRequest, "schemaId is required and cannot be empty") + return + } + resp, err := h.policyService.CreatePolicyMetadata(&req) if err != nil { utils.RespondWithError(w, http.StatusInternalServerError, err.Error()) @@ -109,6 +115,16 @@ func (h *Handler) GetPolicyDecision(w http.ResponseWriter, r *http.Request) { return } + // Validate required fields + if req.ApplicationID == "" { + utils.RespondWithError(w, http.StatusBadRequest, "applicationId is required") + return + } + if len(req.RequiredFields) == 0 { + utils.RespondWithError(w, http.StatusBadRequest, "requiredFields is required and cannot be empty") + return + } + resp, err := h.policyService.GetPolicyDecision(&req) if err != nil { utils.RespondWithError(w, http.StatusInternalServerError, err.Error()) diff --git a/exchange/policy-decision-point/v1/handler_test.go b/exchange/policy-decision-point/v1/handler_test.go index b8bae0c2..ef5b2778 100644 --- a/exchange/policy-decision-point/v1/handler_test.go +++ b/exchange/policy-decision-point/v1/handler_test.go @@ -13,11 +13,13 @@ import ( "gorm.io/gorm" ) +// setupTestDB creates an in-memory SQLite database for unit testing. func setupTestDB(t *testing.T) *gorm.DB { return testhelpers.SetupTestDB(t) } func TestHandler_CreatePolicyMetadata_InvalidJSON(t *testing.T) { + // This test doesn't need a database - it only tests JSON parsing db := setupTestDB(t) handler := NewHandler(db) @@ -31,6 +33,7 @@ func TestHandler_CreatePolicyMetadata_InvalidJSON(t *testing.T) { } func TestHandler_UpdateAllowList_InvalidJSON(t *testing.T) { + // This test doesn't need a database - it only tests JSON parsing db := setupTestDB(t) handler := NewHandler(db) @@ -44,6 +47,7 @@ func TestHandler_UpdateAllowList_InvalidJSON(t *testing.T) { } func TestHandler_GetPolicyDecision_InvalidJSON(t *testing.T) { + // This test doesn't need a database - it only tests JSON parsing db := setupTestDB(t) handler := NewHandler(db) @@ -560,7 +564,7 @@ func TestHandler_GetPolicyDecision(t *testing.T) { ApplicationID: "", RequiredFields: []models.PolicyDecisionRequestRecord{}, }, - expectedStatus: http.StatusOK, // Handler doesn't validate, service will handle + expectedStatus: http.StatusBadRequest, // Handler validates required fields }, { name: "Service error - schema not found", @@ -643,7 +647,7 @@ func TestHandler_handlePolicyService(t *testing.T) { name: "POST /api/v1/policy/decide", method: http.MethodPost, path: "/api/v1/policy/decide", - expectedStatus: http.StatusOK, // Endpoint exists, will process request + expectedStatus: http.StatusBadRequest, // Endpoint exists, but empty body fails validation (applicationId required) }, { name: "GET /api/v1/policy/metadata - Method not allowed", @@ -693,6 +697,24 @@ func TestHandler_handlePolicyService(t *testing.T) { path: "/api/v1/policy/", expectedStatus: http.StatusNotFound, }, + { + name: "Invalid path - three segments", + method: http.MethodPost, + path: "/api/v1/policy/metadata/extra/segment", + expectedStatus: http.StatusNotFound, + }, + { + name: "PATCH method not allowed", + method: http.MethodPatch, + path: "/api/v1/policy/metadata", + expectedStatus: http.StatusMethodNotAllowed, + }, + { + name: "OPTIONS method not allowed", + method: http.MethodOptions, + path: "/api/v1/policy/metadata", + expectedStatus: http.StatusMethodNotAllowed, + }, } for _, tt := range tests { diff --git a/exchange/policy-decision-point/v1/services/policy_metadata_service_test.go b/exchange/policy-decision-point/v1/services/policy_metadata_service_test.go index 4ff87509..47640d91 100644 --- a/exchange/policy-decision-point/v1/services/policy_metadata_service_test.go +++ b/exchange/policy-decision-point/v1/services/policy_metadata_service_test.go @@ -7,10 +7,10 @@ import ( "github.com/gov-dx-sandbox/exchange/policy-decision-point/v1/models" "github.com/gov-dx-sandbox/exchange/policy-decision-point/v1/testhelpers" "github.com/stretchr/testify/assert" - "gorm.io/driver/sqlite" "gorm.io/gorm" ) +// setupTestDB creates an in-memory SQLite database for unit testing. func setupTestDB(t *testing.T) *gorm.DB { return testhelpers.SetupTestDB(t) } @@ -752,234 +752,16 @@ func TestPolicyMetadataService_GetPolicyDecision_EdgeCases(t *testing.T) { } // Error path tests for CreatePolicyMetadata -func TestPolicyMetadataService_CreatePolicyMetadata_ErrorPaths(t *testing.T) { - t.Run("CreatePolicyMetadata_TransactionBeginError", func(t *testing.T) { - // Create a closed/invalid DB connection - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // Close the underlying connection to simulate error - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - service := NewPolicyMetadataService(db) - - req := &models.PolicyMetadataCreateRequest{ - SchemaID: "schema-123", - Records: []models.PolicyMetadataCreateRequestRecord{ - { - FieldName: "field1", - Source: models.SourcePrimary, - IsOwner: true, - AccessControlType: models.AccessControlTypePublic, - }, - }, - } - - _, err = service.CreatePolicyMetadata(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to begin transaction") - }) - - t.Run("CreatePolicyMetadata_FetchExistingError", func(t *testing.T) { - // Create a closed DB connection - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // Create table first - createTableSQL := ` - CREATE TABLE IF NOT EXISTS policy_metadata ( - id TEXT PRIMARY KEY, - schema_id TEXT NOT NULL, - field_name TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'fallback', - is_owner INTEGER NOT NULL DEFAULT 0, - access_control_type TEXT NOT NULL DEFAULT 'restricted', - allow_list TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(schema_id, field_name) - ) - ` - db.Exec(createTableSQL) - - // Close connection after table creation - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - service := NewPolicyMetadataService(db) - - req := &models.PolicyMetadataCreateRequest{ - SchemaID: "schema-123", - Records: []models.PolicyMetadataCreateRequestRecord{ - { - FieldName: "field1", - Source: models.SourcePrimary, - IsOwner: true, - AccessControlType: models.AccessControlTypePublic, - }, - }, - } - - _, err = service.CreatePolicyMetadata(req) - assert.Error(t, err) - }) - - // TODO: Add integration tests or mock database tests to explicitly cover delete/create/update - // error scenarios (lines 100-103, 108-111, 122-125 in policy_metadata_service.go). - - t.Run("CreatePolicyMetadata_CommitError", func(t *testing.T) { - db := setupTestDB(t) - service := NewPolicyMetadataService(db) - - // Create a record first - initialReq := &models.PolicyMetadataCreateRequest{ - SchemaID: "schema-123", - Records: []models.PolicyMetadataCreateRequestRecord{ - { - FieldName: "field1", - Source: models.SourcePrimary, - IsOwner: true, - AccessControlType: models.AccessControlTypePublic, - }, - }, - } - _, err := service.CreatePolicyMetadata(initialReq) - assert.NoError(t, err) - - // Close the connection to cause commit error - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - // Try to create new record (will fail on commit) - req := &models.PolicyMetadataCreateRequest{ - SchemaID: "schema-123", - Records: []models.PolicyMetadataCreateRequestRecord{ - { - FieldName: "field2", - Source: models.SourcePrimary, - IsOwner: true, - AccessControlType: models.AccessControlTypePublic, - }, - }, - } - - _, err = service.CreatePolicyMetadata(req) - assert.Error(t, err) - }) -} +// Note: Tests that require closing database connections to simulate errors have been removed +// as they cannot be properly mocked with SQLite. These error scenarios should be tested +// in integration tests with a real PostgreSQL database. // Error path tests for UpdateAllowList -func TestPolicyMetadataService_UpdateAllowList_ErrorPaths(t *testing.T) { - t.Run("UpdateAllowList_TransactionBeginError", func(t *testing.T) { - // Create a closed/invalid DB connection - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // Create table first - createTableSQL := ` - CREATE TABLE IF NOT EXISTS policy_metadata ( - id TEXT PRIMARY KEY, - schema_id TEXT NOT NULL, - field_name TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'fallback', - is_owner INTEGER NOT NULL DEFAULT 0, - access_control_type TEXT NOT NULL DEFAULT 'restricted', - allow_list TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(schema_id, field_name) - ) - ` - db.Exec(createTableSQL) - - // Close the underlying connection - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - service := NewPolicyMetadataService(db) - - req := &models.AllowListUpdateRequest{ - ApplicationID: "app-123", - GrantDuration: models.GrantDurationTypeOneMonth, - Records: []models.AllowListUpdateRequestRecord{ - { - FieldName: "field1", - SchemaID: "schema-123", - }, - }, - } - - _, err = service.UpdateAllowList(req) - assert.Error(t, err) - }) - - t.Run("UpdateAllowList_FetchError", func(t *testing.T) { - // Create a closed DB connection - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // Close connection immediately - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - service := NewPolicyMetadataService(db) - - req := &models.AllowListUpdateRequest{ - ApplicationID: "app-123", - GrantDuration: models.GrantDurationTypeOneMonth, - Records: []models.AllowListUpdateRequestRecord{ - { - FieldName: "field1", - SchemaID: "schema-123", - }, - }, - } - - _, err = service.UpdateAllowList(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to fetch policy metadata records") - }) - - // Note: Testing commit errors with SQLite in-memory is not feasible because: - // 1. SQLite in-memory databases don't support simulating commit failures in a controlled way - // 2. Closing the connection before commit causes errors at earlier stages (fetch/update) - // 3. The commit error handling code path exists in the service (line 272-273 in policy_metadata_service.go) - // and would be better tested with integration tests using a real PostgreSQL database - // 4. Transaction begin errors and fetch errors are already covered in other tests -} +// Note: Tests that require closing database connections to simulate errors have been removed +// as they cannot be properly mocked with SQLite. These error scenarios should be tested +// in integration tests with a real PostgreSQL database. // Error path tests for GetPolicyDecision -func TestPolicyMetadataService_GetPolicyDecision_ErrorPaths(t *testing.T) { - t.Run("GetPolicyDecision_FetchError", func(t *testing.T) { - // Create a closed DB connection - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // Close connection immediately - sqlDB, err := db.DB() - assert.NoError(t, err) - sqlDB.Close() - - service := NewPolicyMetadataService(db) - - req := &models.PolicyDecisionRequest{ - ApplicationID: "app-123", - RequiredFields: []models.PolicyDecisionRequestRecord{ - { - FieldName: "field1", - SchemaID: "schema-123", - }, - }, - } - - _, err = service.GetPolicyDecision(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to fetch policy metadata records") - }) -} +// Note: Tests that require closing database connections to simulate errors have been removed +// as they cannot be properly mocked with SQLite. These error scenarios should be tested +// in integration tests with a real PostgreSQL database. diff --git a/exchange/policy-decision-point/v1/test_helpers.go b/exchange/policy-decision-point/v1/test_helpers.go index cfc34924..b2cffddd 100644 --- a/exchange/policy-decision-point/v1/test_helpers.go +++ b/exchange/policy-decision-point/v1/test_helpers.go @@ -61,3 +61,15 @@ func TestEnvVars() map[string]string { "DB_SSLMODE": "disable", } } + +// TestEnvVarsStandard returns standard environment variables for testing +func TestEnvVarsStandard() map[string]string { + return map[string]string{ + "DB_HOST": "standard-host", + "DB_PORT": "5434", + "DB_USERNAME": "standard-user", + "DB_PASSWORD": "standard-password", + "DB_NAME": "standard-db", + "DB_SSLMODE": "prefer", + } +} diff --git a/exchange/policy-decision-point/v1/testhelpers/helpers.go b/exchange/policy-decision-point/v1/testhelpers/helpers.go index 396d33fd..3a3e7b59 100644 --- a/exchange/policy-decision-point/v1/testhelpers/helpers.go +++ b/exchange/policy-decision-point/v1/testhelpers/helpers.go @@ -1,29 +1,35 @@ package testhelpers import ( + "fmt" + "os" + "regexp" + "strings" "testing" + "github.com/gov-dx-sandbox/exchange/policy-decision-point/v1/models" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" - - "github.com/gov-dx-sandbox/exchange/policy-decision-point/v1/models" ) // StringPtr returns a pointer to the given string value. -// This is a convenience helper for test code that needs string pointers. func StringPtr(s string) *string { return &s } // OwnerPtr returns a pointer to the given Owner value. -// This is a convenience helper for test code that needs Owner pointers. func OwnerPtr(o models.Owner) *models.Owner { return &o } -// SetupTestDB creates an in-memory SQLite database for testing. +// SetupTestDB creates an in-memory SQLite database for unit testing. // It creates the policy_metadata table with SQLite-compatible schema. // SQLite doesn't support PostgreSQL-specific features like gen_random_uuid(), enums, jsonb. +// This is fast and doesn't require a real database connection. +// +// For integration-style tests that need real PostgreSQL behavior (transactions, complex queries), +// use SetupPostgresTestDB instead. func SetupTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -54,3 +60,151 @@ func SetupTestDB(t *testing.T) *gorm.DB { return db } + +// getEnvOrDefault returns the environment variable value or a default +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// isValidDBName checks if the database name is safe to use in SQL +func isValidDBName(name string) bool { + match, _ := regexp.MatchString("^[a-zA-Z0-9_]+$", name) + return match +} + +// SetupPostgresTestDB creates a PostgreSQL test database connection for integration tests. +// This function should ONLY be used in integration tests or tests that explicitly require +// real database behavior (e.g., testing transactions, complex queries, or GORM-specific features). +// +// All database testing should be done via integration tests in tests/integration/. +// +// This function will skip the test if a database connection cannot be established. +func SetupPostgresTestDB(t *testing.T) *gorm.DB { + host := getEnvOrDefault("TEST_DB_HOST", "localhost") + port := getEnvOrDefault("TEST_DB_PORT", "5432") + testDB := getEnvOrDefault("TEST_DB_DATABASE", "pdp_service_test") + sslmode := getEnvOrDefault("TEST_DB_SSLMODE", "disable") + + // Try credential combinations + credentials := []struct { + user string + pass string + }{ + {getEnvOrDefault("TEST_DB_USERNAME", "postgres"), getEnvOrDefault("TEST_DB_PASSWORD", "password")}, + {"postgres", "password"}, + {"postgres", ""}, + {os.Getenv("USER"), ""}, + } + + var db *gorm.DB + var err error + + for _, cred := range credentials { + if cred.user == "" { + continue + } + + // 1. Try connecting to the test database directly + db, err = tryConnection(host, port, cred.user, cred.pass, testDB, sslmode) + if err == nil { + t.Logf("Connected to PostgreSQL with user=%s", cred.user) + return setupDatabase(t, db) + } + + // 2. If test database doesn't exist, try to connect to default database and create it + if isDBNotExistError(err) { + defaultDB := "postgres" + if adminDB, adminErr := tryConnection(host, port, cred.user, cred.pass, defaultDB, sslmode); adminErr == nil { + t.Logf("Connected to admin database, creating test database") + + // SECURITY NOTE: CREATE DATABASE cannot be parameterized in PostgreSQL, requiring + // string formatting which is inherently risky for SQL injection. To mitigate this risk: + // 1. We validate both testDB and cred.user with isValidDBName() using strict regex ^[a-zA-Z0-9_]+$ + // 2. We double-quote identifiers to prevent keyword conflicts + // 3. The validation ensures only alphanumeric characters and underscores are allowed + // This pattern should be used with extreme caution and only in test code with controlled inputs. + if !isValidDBName(testDB) { + t.Fatalf("Invalid database name: %s", testDB) + } + if !isValidDBName(cred.user) { + t.Fatalf("Invalid database owner: %s", cred.user) + } + + // Create test database using validated inputs + createSQL := fmt.Sprintf("CREATE DATABASE \"%s\" WITH OWNER = \"%s\"", testDB, cred.user) + if createErr := adminDB.Exec(createSQL).Error; createErr != nil { + // Database might already exist (race condition), ignore error + t.Logf("Note: Could not create test database: %v", createErr) + } + + // Close admin connection properly + if sqlDB, err := adminDB.DB(); err == nil { + sqlDB.Close() + } + + // Now try connecting to the test database again + db, err = tryConnection(host, port, cred.user, cred.pass, testDB, sslmode) + if err == nil { + t.Logf("Successfully created and connected to test database with user=%s", cred.user) + return setupDatabase(t, db) + } + } + } + } + + if err != nil { + t.Skipf("Skipping test: could not connect to test database with any credentials: %v", err) + return nil + } + + return setupDatabase(t, db) +} + +// tryConnection attempts to connect to PostgreSQL with given credentials +func tryConnection(host, port, user, password, database, sslmode string) (*gorm.DB, error) { + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + host, port, user, password, database, sslmode) + return gorm.Open(postgres.Open(dsn), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) +} + +// isDBNotExistError checks if the error is due to database not existing +func isDBNotExistError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "3D000") +} + +// setupDatabase performs migration and cleanup for the test database +func setupDatabase(t *testing.T, db *gorm.DB) *gorm.DB { + // Auto-migrate all models + err := db.AutoMigrate( + &models.PolicyMetadata{}, + ) + if err != nil { + t.Skipf("Skipping test: could not migrate test database: %v", err) + return nil + } + + // Clean up test data before each test + CleanupTestData(t, db) + + return db +} + +// CleanupTestData removes all test data from the database +func CleanupTestData(t *testing.T, db *gorm.DB) { + if db == nil { + return + } + + // Delete all policy metadata + if err := db.Exec("DELETE FROM policy_metadata").Error; err != nil { + t.Logf("Warning: could not cleanup policy_metadata: %v", err) + } +} diff --git a/exchange/policy-decision-point/v1/testhelpers/helpers_test.go b/exchange/policy-decision-point/v1/testhelpers/helpers_test.go new file mode 100644 index 00000000..7bd8db04 --- /dev/null +++ b/exchange/policy-decision-point/v1/testhelpers/helpers_test.go @@ -0,0 +1,161 @@ +package testhelpers + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestGetEnvOrDefault(t *testing.T) { + // Test with existing env var + os.Setenv("TEST_ENV_VAR", "test-value") + defer os.Unsetenv("TEST_ENV_VAR") + + value := getEnvOrDefault("TEST_ENV_VAR", "default-value") + assert.Equal(t, "test-value", value) +} + +func TestGetEnvOrDefault_DefaultValue(t *testing.T) { + // Ensure env var is not set + os.Unsetenv("NONEXISTENT_VAR") + + value := getEnvOrDefault("NONEXISTENT_VAR", "default-value") + assert.Equal(t, "default-value", value) +} + +func TestIsValidDBName(t *testing.T) { + validNames := []string{ + "test_db", + "testdb123", + "TEST_DB", + "test", + "db_123", + "a", + "123", + "_test", + "test_", + } + + for _, name := range validNames { + assert.True(t, isValidDBName(name), "Expected %s to be valid", name) + } +} + +func TestIsValidDBName_Invalid(t *testing.T) { + invalidNames := []string{ + "test-db", // hyphen not allowed + "test.db", // dot not allowed + "test db", // space not allowed + "test@db", // special char not allowed + "test/db", // slash not allowed + "", // empty not allowed + "test.db.name", // multiple dots + "test\ndb", // newline not allowed + "test\tdb", // tab not allowed + } + + for _, name := range invalidNames { + assert.False(t, isValidDBName(name), "Expected %s to be invalid", name) + } +} + +func TestIsDBNotExistError(t *testing.T) { + // Test with error containing "does not exist" + err1 := &testError{message: "database does not exist"} + assert.True(t, isDBNotExistError(err1)) + + // Test with error containing "3D000" + err2 := &testError{message: "error code 3D000"} + assert.True(t, isDBNotExistError(err2)) + + // Test with nil error + assert.False(t, isDBNotExistError(nil)) + + // Test with different error + err3 := &testError{message: "connection refused"} + assert.False(t, isDBNotExistError(err3)) +} + +func TestIsDBNotExistError_VariousErrors(t *testing.T) { + tests := []struct { + name string + errMsg string + want bool + }{ + { + name: "Error with 'does not exist'", + errMsg: "database 'testdb' does not exist", + want: true, + }, + { + name: "Error with '3D000'", + errMsg: "error code 3D000: database not found", + want: true, + }, + { + name: "Error with both patterns", + errMsg: "database does not exist (3D000)", + want: true, + }, + { + name: "Connection refused", + errMsg: "connection refused", + want: false, + }, + { + name: "Authentication failed", + errMsg: "password authentication failed", + want: false, + }, + { + name: "Empty error message", + errMsg: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &testError{message: tt.errMsg} + got := isDBNotExistError(err) + assert.Equal(t, tt.want, got) + }) + } +} + +// testError is a simple error type for testing +type testError struct { + message string +} + +func (e *testError) Error() string { + return e.message +} + +func TestCleanupTestData_WithValidDB(t *testing.T) { + // Use SQLite in-memory database for testing CleanupTestData + // Note: SQLite doesn't support PostgreSQL-specific features, so we test + // that CleanupTestData handles the case where table doesn't exist gracefully + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + defer func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }() + + // Test that CleanupTestData doesn't panic even if table doesn't exist + // (SQLite can't create the PostgreSQL-specific schema) + CleanupTestData(t, db) + + // Should not panic - CleanupTestData should handle missing table gracefully + assert.True(t, true) +} + +// NOTE: TestSetupPostgresTestDB_NoConnection has been moved to +// tests/integration/database/pdp_database_test.go as an integration test. +// Unit tests should not use real database connections. diff --git a/tests/integration/README.md b/tests/integration/README.md index a08c1bbf..8dc9a04e 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -43,16 +43,39 @@ docker compose -f docker-compose.test.yml down -v ## Test Structure +### Directory Structure + ``` tests/integration/ -├── graphql_flow_test.go # GraphQL workflow tests -├── services_integration_test.go # Service health checks -├── docker-compose.test.yml # Test services configuration +├── README.md # This file +├── docker-compose.test.yml # Docker Compose configuration for tests +├── docker-compose.db.yml # Database-only Docker Compose configuration +├── go.mod # Go module definition +├── go.sum # Go module checksums +├── run-local-tests.sh # Automated test script ├── schema.graphql # GraphQL schema for tests ├── config.json # Orchestration Engine config -└── testutils/ # Test utilities - ├── db.go # Database helpers - └── http.go # HTTP client helpers +├── init-consent-db.sql # Consent database initialization script +├── init-stats.sh # Statistics initialization script +├── graphql_flow_test.go # GraphQL workflow tests +├── services_integration_test.go # Service health checks +├── audit_flow_integration_test.go # Audit flow integration tests +├── audit_traceid_test.go # Audit trace ID tests +├── consent/ # Consent Engine integration tests +│ └── consent_test.go +├── policy/ # Policy Decision Point integration tests +│ └── policy_test.go +├── database/ # Database connection integration tests +│ ├── consent_database_test.go # Consent Engine database tests +│ └── pdp_database_test.go # Policy Decision Point database tests +├── testutils/ # Test utilities +│ ├── db.go # Database helpers +│ └── http.go # HTTP client helpers +├── bin/ # Compiled binaries (not committed) +│ └── consent-engine +└── mock-provider/ # Mock provider service + ├── Dockerfile + └── main.go ``` --- diff --git a/tests/integration/consent/consent_test.go b/tests/integration/consent/consent_test.go new file mode 100644 index 00000000..00d274a4 --- /dev/null +++ b/tests/integration/consent/consent_test.go @@ -0,0 +1,655 @@ +package consent + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "testing" + + "github.com/gov-dx-sandbox/tests/integration/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + consentBaseURL = "http://127.0.0.1:8081" +) + +// Request/Response types for type safety + +type ConsentField struct { + FieldName string `json:"fieldName"` + SchemaID string `json:"schemaId"` +} + +type ConsentRequirement struct { + Owner string `json:"owner"` + OwnerID string `json:"ownerId"` + OwnerEmail string `json:"ownerEmail"` + Fields []ConsentField `json:"fields"` +} + +type CreateConsentRequest struct { + AppID string `json:"appId"` + ConsentRequirement ConsentRequirement `json:"consentRequirement"` +} + +type CreateConsentResponse struct { + ConsentID string `json:"consentId"` + Status string `json:"status"` + ConsentPortalURL *string `json:"consentPortalUrl,omitempty"` + Fields *[]ConsentField `json:"fields,omitempty"` +} + +type Consent struct { + ConsentID string `json:"consentId"` + Status string `json:"status"` + // Add other fields as needed based on API response +} + +type UpdateConsentRequest struct { + Status string `json:"status"` + UpdatedBy string `json:"updated_by"` +} + +type UpdateConsentResponse struct { + Status string `json:"status"` +} + +func TestMain(m *testing.M) { + // Wait for consent engine service availability + if err := testutils.WaitForService(consentBaseURL+"/health", 30); err != nil { + fmt.Printf("Consent Engine service not available: %v\n", err) + os.Exit(1) + } + + code := m.Run() + os.Exit(code) +} + +// TestConsent_CreateAndRetrieve tests basic consent creation and retrieval +func TestConsent_CreateAndRetrieve(t *testing.T) { + appID := "test-app-consent-1" + ownerID := "test-owner-123" + fieldName := "personInfo.name" + schemaID := "test-schema-123" + + // Create consent request + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: fieldName, + SchemaID: schemaID, + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + // Create consent + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Expected 201 Created") + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + assert.NotEmpty(t, consentID, "consent_id should not be empty") + assert.Equal(t, "pending", createResponse.Status, "New consent should have pending status") + + // Cleanup: Remove created consent record + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) + + // Retrieve consent (internal endpoint - no auth required for testing) + // Returns a single consent object, not an array + resp, err = http.Get(consentBaseURL + "/internal/api/v1/consents?ownerId=" + ownerID + "&appId=" + appID) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK") + + var retrieveResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&retrieveResponse) + require.NoError(t, err) + require.NotEmpty(t, retrieveResponse.ConsentID, "Expected consent ID to be present") + + // Verify consent matches + assert.Equal(t, consentID, retrieveResponse.ConsentID, "Retrieved consent ID should match created consent") + assert.Equal(t, "pending", retrieveResponse.Status, "Consent status should be pending") +} + +// TestConsent_InvalidRequest tests edge cases for invalid consent requests +func TestConsent_InvalidRequest(t *testing.T) { + tests := []struct { + name string + request func() []byte + expectedStatus int + }{ + { + name: "Missing appId", + request: func() []byte { + req := map[string]interface{}{ + "consentRequirement": map[string]interface{}{ + "owner": "citizen", + "ownerId": "test-owner", + "ownerEmail": "test@example.com", + "fields": []map[string]interface{}{}, + }, + } + body, _ := json.Marshal(req) + return body + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Missing consentRequirement", + request: func() []byte { + req := CreateConsentRequest{ + AppID: "test-app", + } + body, _ := json.Marshal(req) + return body + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Missing ownerId", + request: func() []byte { + req := map[string]interface{}{ + "appId": "test-app", + "consentRequirement": map[string]interface{}{ + "owner": "citizen", + "ownerEmail": "test@example.com", + "fields": []map[string]interface{}{}, + }, + } + body, _ := json.Marshal(req) + return body + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Empty fields", + request: func() []byte { + req := CreateConsentRequest{ + AppID: "test-app", + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: "test-owner", + OwnerEmail: "test@example.com", + Fields: []ConsentField{}, + }, + } + body, _ := json.Marshal(req) + return body + }, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := tt.request() + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tt.expectedStatus, resp.StatusCode, + "Expected status %d for invalid request: %s", tt.expectedStatus, tt.name) + }) + } +} + +// TestConsent_GetByConsumer tests retrieving consents by appId and ownerId +func TestConsent_GetByConsumer(t *testing.T) { + appID := "test-app-consumer-1" + ownerID := "test-owner-consumer-1" + + // Create consent + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + assert.NotEmpty(t, createResponse.ConsentID) + + // Retrieve by appId and ownerId (internal endpoint returns single consent object) + resp, err = http.Get(consentBaseURL + "/internal/api/v1/consents?appId=" + appID + "&ownerId=" + ownerID) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return the consent we created + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK") + + var retrievedConsent CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&retrievedConsent) + require.NoError(t, err) + require.NotEmpty(t, retrievedConsent.ConsentID, "Expected to retrieve the created consent") + assert.Equal(t, createResponse.ConsentID, retrievedConsent.ConsentID, "Retrieved consent ID should match created consent ID") + + // Cleanup: Remove created consent record + consentID := createResponse.ConsentID + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) +} + +// TestConsent_StatusUpdate tests consent status updates +// Note: This test may require JWT authentication in production +// For integration tests, we test the internal PATCH endpoint if available +func TestConsent_StatusUpdate(t *testing.T) { + appID := "test-app-update-1" + ownerID := "test-owner-update-1" + + // Create consent + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + require.NotEmpty(t, consentID) + + // Update consent status using PUT (portal endpoint requires JWT auth) + // For integration tests, we verify the consent was created successfully + // Status updates require JWT authentication which is tested separately + assert.Equal(t, "pending", createResponse.Status, "New consent should have pending status") + + // Cleanup: Remove created consent record + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) +} + +// TestConsent_HealthCheck tests the health check endpoint +func TestConsent_HealthCheck(t *testing.T) { + // Health check endpoint exists + resp, err := http.Get(consentBaseURL + "/internal/api/v1/health") + require.NoError(t, err) + defer resp.Body.Close() + + // Health check should return 200 OK + assert.Equal(t, http.StatusOK, resp.StatusCode, "Health check should return 200 OK") +} + +// TestConsent_DatabaseVerification tests consent creation with database verification +func TestConsent_DatabaseVerification(t *testing.T) { + if os.Getenv("TEST_VERIFY_DB") != "true" { + t.Skip("Skipping database verification test (set TEST_VERIFY_DB=true to enable)") + } + + db := testutils.SetupConsentDB(t) + if db == nil { + t.Skip("Database connection not available") + return + } + + appID := "test-app-db-1" + ownerID := "test-owner-db-1" + + // Create consent + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + require.NotEmpty(t, consentID) + + // Verify consent exists in database + var count int64 + err = db.Table("consent_records"). + Where("consent_id = ?", consentID). + Count(&count).Error + + require.NoError(t, err) + assert.Greater(t, count, int64(0), "Consent should exist in database") + + // Cleanup + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) +} + +// TestConsent_GetByOwnerEmail tests retrieving consent by owner email +func TestConsent_GetByOwnerEmail(t *testing.T) { + appID := "test-app-email-1" + ownerID := "test-owner-email-1" + ownerEmail := ownerID + "@example.com" + + // Create consent + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerEmail, + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + require.NotEmpty(t, consentID) + + // Cleanup + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) + + // Retrieve by owner email + resp, err = http.Get(consentBaseURL + "/internal/api/v1/consents?ownerEmail=" + url.QueryEscape(ownerEmail) + "&appId=" + appID) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK") + + var retrievedConsent CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&retrievedConsent) + require.NoError(t, err) + assert.Equal(t, consentID, retrievedConsent.ConsentID, "Retrieved consent ID should match created consent") +} + +// TestConsent_MultipleFields tests creating consent with multiple fields +func TestConsent_MultipleFields(t *testing.T) { + appID := "test-app-multi-1" + ownerID := "test-owner-multi-1" + + // Create consent with multiple fields + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + { + FieldName: "personInfo.address", + SchemaID: "test-schema-123", + }, + { + FieldName: "personInfo.phone", + SchemaID: "test-schema-456", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + require.NotEmpty(t, consentID) + assert.Equal(t, "pending", createResponse.Status, "New consent should have pending status") + + // Verify fields are present + if createResponse.Fields != nil { + assert.Equal(t, 3, len(*createResponse.Fields), "Should have 3 fields") + } + + // Cleanup + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) +} + +// TestConsent_NotFound tests retrieving non-existent consent +func TestConsent_NotFound(t *testing.T) { + // Try to retrieve a consent that doesn't exist + resp, err := http.Get(consentBaseURL + "/internal/api/v1/consents?ownerId=nonexistent-owner&appId=nonexistent-app") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404 Not Found") +} + +// TestConsent_MissingAppId tests GET request without appId +func TestConsent_MissingAppId(t *testing.T) { + // Try to retrieve consent without appId (required parameter) + resp, err := http.Get(consentBaseURL + "/internal/api/v1/consents?ownerId=test-owner") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Should return 400 Bad Request for missing appId") +} + +// TestConsent_MissingOwnerIdentifier tests GET request without ownerEmail or ownerId +func TestConsent_MissingOwnerIdentifier(t *testing.T) { + // Try to retrieve consent without ownerEmail or ownerId (required parameter) + resp, err := http.Get(consentBaseURL + "/internal/api/v1/consents?appId=test-app") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Should return 400 Bad Request for missing owner identifier") +} + +// TestConsent_DuplicateCreation tests creating duplicate consent (same appId, ownerId, fields) +func TestConsent_DuplicateCreation(t *testing.T) { + appID := "test-app-dup-1" + ownerID := "test-owner-dup-1" + + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + // Create first consent + resp1, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp1.Body.Close() + + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + + var createResponse1 CreateConsentResponse + err = json.NewDecoder(resp1.Body).Decode(&createResponse1) + require.NoError(t, err) + + consentID1 := createResponse1.ConsentID + require.NotEmpty(t, consentID1) + + // Cleanup + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID1) + }) + + // Try to create duplicate consent (same appId, ownerId, fields) + reqBody2, err := json.Marshal(createReq) + require.NoError(t, err) + + resp2, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody2)) + require.NoError(t, err) + defer resp2.Body.Close() + + // Service may return 201 (idempotent) or 400 (duplicate), both are acceptable + assert.Contains(t, []int{http.StatusCreated, http.StatusBadRequest}, resp2.StatusCode, + "Duplicate creation should return 201 (idempotent) or 400 (duplicate)") + + if resp2.StatusCode == http.StatusCreated { + var createResponse2 CreateConsentResponse + err = json.NewDecoder(resp2.Body).Decode(&createResponse2) + require.NoError(t, err) + consentID2 := createResponse2.ConsentID + // If idempotent, may return same consent ID + if consentID2 != "" && consentID2 != consentID1 { + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID2) + }) + } + } +} + +// TestConsent_DifferentSchemas tests creating consent with fields from different schemas +func TestConsent_DifferentSchemas(t *testing.T) { + appID := "test-app-schema-1" + ownerID := "test-owner-schema-1" + + createReq := CreateConsentRequest{ + AppID: appID, + ConsentRequirement: ConsentRequirement{ + Owner: "citizen", + OwnerID: ownerID, + OwnerEmail: ownerID + "@example.com", + Fields: []ConsentField{ + { + FieldName: "personInfo.name", + SchemaID: "test-schema-123", + }, + { + FieldName: "vehicleInfo.licensePlate", + SchemaID: "test-schema-456", + }, + }, + }, + } + + reqBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(consentBaseURL+"/internal/api/v1/consents", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse CreateConsentResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + consentID := createResponse.ConsentID + require.NotEmpty(t, consentID) + + // Cleanup + t.Cleanup(func() { + testutils.CleanupConsentRecord(t, consentID) + }) +} diff --git a/tests/integration/database/consent_database_test.go b/tests/integration/database/consent_database_test.go new file mode 100644 index 00000000..f9091279 --- /dev/null +++ b/tests/integration/database/consent_database_test.go @@ -0,0 +1,35 @@ +package database + +import ( + "testing" + + "github.com/gov-dx-sandbox/tests/integration/testutils" + "github.com/stretchr/testify/assert" +) + +// TestConsentEngine_DatabaseConnection tests real database connection for Consent Engine +func TestConsentEngine_DatabaseConnection(t *testing.T) { + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5434", + Database: "consent_db", + Password: "password", + }) + defer cleanup() + + // Test that we can connect to the database + db := testutils.SetupPostgresTestDB(t) + if db == nil { + t.Skip("Database connection not available") + return + } + + // Test connection by pinging + sqlDB, err := db.DB() + assert.NoError(t, err) + assert.NoError(t, sqlDB.Ping()) +} + +// NOTE: Tests for ConnectGormDB with invalid configs have been removed. +// These tests require importing service packages which creates module dependency issues. +// Connection error handling is tested at the service level in unit tests. + diff --git a/tests/integration/database/pdp_database_test.go b/tests/integration/database/pdp_database_test.go new file mode 100644 index 00000000..f5c02efd --- /dev/null +++ b/tests/integration/database/pdp_database_test.go @@ -0,0 +1,53 @@ +package database + +import ( + "testing" + + "github.com/gov-dx-sandbox/tests/integration/testutils" + "github.com/stretchr/testify/assert" +) + +// TestPolicyDecisionPoint_DatabaseConnection tests real database connection for Policy Decision Point +func TestPolicyDecisionPoint_DatabaseConnection(t *testing.T) { + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + // Test that we can connect to the database + db := testutils.SetupPostgresTestDB(t) + if db == nil { + t.Skip("Database connection not available") + return + } + + // Test connection by pinging + sqlDB, err := db.DB() + assert.NoError(t, err) + assert.NoError(t, sqlDB.Ping()) +} + +// NOTE: Tests for ConnectGormDB with invalid configs have been removed. +// These tests require importing service packages which creates module dependency issues. +// Connection error handling is tested at the service level in unit tests. + +// TestPolicyDecisionPoint_SetupPostgresTestDB_NoConnection tests that SetupPostgresTestDB handles connection failures gracefully +func TestPolicyDecisionPoint_SetupPostgresTestDB_NoConnection(t *testing.T) { + cleanup := testutils.WithTestDBEnvFull(t, map[string]string{ + "TEST_DB_HOST": "invalid-host-that-does-not-exist", + "TEST_DB_PORT": "5432", + "TEST_DB_USERNAME": "invalid-user", + "TEST_DB_PASSWORD": "invalid-pass", + "TEST_DB_DATABASE": "invalid-db", + }) + defer cleanup() + + // Should skip the test gracefully + db := testutils.SetupPostgresTestDB(t) + if db == nil { + t.Skip("Database connection not available - this is expected") + } +} + diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index 7bae36f3..7df9e299 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -7,12 +7,12 @@ services: environment: PORT: 8082 LOG_LEVEL: info + DB_HOST: pdp-db + DB_PORT: 5432 + DB_USERNAME: postgres + DB_PASSWORD: password + DB_NAME: policy_db DB_SSLMODE: disable - CHOREO_OPENDIF_DATABASE_HOSTNAME: pdp-db - CHOREO_OPENDIF_DATABASE_PORT: 5432 - CHOREO_OPENDIF_DATABASE_USERNAME: postgres - CHOREO_OPENDIF_DATABASE_PASSWORD: password - CHOREO_OPENDIF_DATABASE_DATABASENAME: policy_db RUN_MIGRATION: "true" ports: - "8082:8082" diff --git a/tests/integration/policy/policy_error_paths_test.go b/tests/integration/policy/policy_error_paths_test.go new file mode 100644 index 00000000..4fe2bb06 --- /dev/null +++ b/tests/integration/policy/policy_error_paths_test.go @@ -0,0 +1,368 @@ +package policy + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/gov-dx-sandbox/tests/integration/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPolicy_CreateMetadata_TransactionRollback tests that invalid requests +// are properly rejected and don't leave partial data in the database. +// This requires a real PostgreSQL database to test transaction rollback behavior. +func TestPolicy_CreateMetadata_TransactionRollback(t *testing.T) { + schemaID := generateTestID("test-schema-rollback") + + // Create a request that should fail validation (isOwner false but no owner specified) + req := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "field1", + Source: "primary", + IsOwner: false, // Invalid: isOwner false but no owner specified + AccessControlType: "public", + }, + }, + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return error due to validation failure + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "Should reject invalid request") + + // Verify transaction was rolled back - no records should exist + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + db := testutils.SetupPDPDB(t) + if db != nil { + var count int64 + db.Table("policy_metadata").Where("schema_id = ?", schemaID).Count(&count) + assert.Equal(t, int64(0), count, "Transaction should have been rolled back, no records should exist") + } + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_CreateMetadata_UniqueConstraintViolation tests that duplicate records +// are handled correctly (should update existing, not create duplicate). +// This tests the unique constraint (schema_id, field_name) behavior with PostgreSQL. +func TestPolicy_CreateMetadata_UniqueConstraintViolation(t *testing.T) { + schemaID := generateTestID("test-schema-unique") + fieldName := "person.fullName" + + // Create initial record + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: fieldName, + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp1, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + defer resp1.Body.Close() + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + + var createResponse1 PolicyMetadataCreateResponse + err = json.NewDecoder(resp1.Body).Decode(&createResponse1) + require.NoError(t, err) + require.Len(t, createResponse1.Records, 1) + originalID := createResponse1.Records[0].ID + + // Create same record again - should update, not create duplicate + displayName := "Updated Name" + updateReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: fieldName, + DisplayName: &displayName, + Source: "primary", + IsOwner: true, + AccessControlType: "restricted", // Changed + }, + }, + } + + updateBody, err := json.Marshal(updateReq) + require.NoError(t, err) + + resp2, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(updateBody)) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusCreated, resp2.StatusCode) + + var createResponse2 PolicyMetadataCreateResponse + err = json.NewDecoder(resp2.Body).Decode(&createResponse2) + require.NoError(t, err) + require.Len(t, createResponse2.Records, 1) + + // Verify it's the same record (same ID) but with updated values + assert.Equal(t, originalID, createResponse2.Records[0].ID, "Should update existing record, not create duplicate") + assert.Equal(t, "Updated Name", *createResponse2.Records[0].DisplayName) + assert.Equal(t, "restricted", createResponse2.Records[0].AccessControlType) + + // Verify only one record exists (no duplicates) by checking database directly + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + db := testutils.SetupPDPDB(t) + if db != nil { + var count int64 + db.Table("policy_metadata").Where("schema_id = ? AND field_name = ?", schemaID, fieldName).Count(&count) + assert.Equal(t, int64(1), count, "Should have exactly one record, no duplicates") + } + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_UpdateAllowList_FieldNotFound tests error handling when +// trying to update allow list for a field that doesn't exist. +func TestPolicy_UpdateAllowList_FieldNotFound(t *testing.T) { + req := AllowListUpdateRequest{ + ApplicationID: "app-123", + Records: []AllowListUpdateRequestRecord{ + { + FieldName: "nonexistent-field", + SchemaID: "nonexistent-schema", + }, + }, + GrantDuration: "30d", + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return error for field not found + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "Should return error for field not found") +} + +// TestPolicy_GetPolicyDecision_FieldNotFound tests error handling when +// trying to get policy decision for a field that doesn't exist. +func TestPolicy_GetPolicyDecision_FieldNotFound(t *testing.T) { + req := PolicyDecisionRequest{ + ApplicationID: "app-123", + RequiredFields: []PolicyDecisionRequestRecord{ + { + FieldName: "nonexistent-field", + SchemaID: "nonexistent-schema", + }, + }, + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + // Should return error for field not found + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "Should return error for field not found") +} + +// TestPolicy_UpdateAllowList_InvalidGrantDuration tests error handling +// for invalid grant duration values. +func TestPolicy_UpdateAllowList_InvalidGrantDuration(t *testing.T) { + schemaID := generateTestID("test-schema-invalid-duration") + + // Create policy metadata first + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "field1", + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Try to update with invalid grant duration + updateReq := AllowListUpdateRequest{ + ApplicationID: "app-123", + GrantDuration: "invalid-duration", + Records: []AllowListUpdateRequestRecord{ + { + FieldName: "field1", + SchemaID: schemaID, + }, + }, + } + + updateBody, err := json.Marshal(updateReq) + require.NoError(t, err) + + updateResp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBuffer(updateBody)) + require.NoError(t, err) + defer updateResp.Body.Close() + + // Should return error for invalid grant duration + assert.Equal(t, http.StatusInternalServerError, updateResp.StatusCode, "Should return error for invalid grant duration") + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_GetPolicyDecision_ExpiredAccess tests that expired access +// is properly detected. This requires real database to test time-based expiration. +func TestPolicy_GetPolicyDecision_ExpiredAccess(t *testing.T) { + schemaID := generateTestID("test-schema-expired") + fieldName := "person.fullName" + appID := generateTestID("test-app-expired") + + // Create policy metadata + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: fieldName, + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Update allow list + updateReq := AllowListUpdateRequest{ + ApplicationID: appID, + GrantDuration: "30d", + Records: []AllowListUpdateRequestRecord{ + { + FieldName: fieldName, + SchemaID: schemaID, + }, + }, + } + + updateBody, err := json.Marshal(updateReq) + require.NoError(t, err) + + updateResp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBuffer(updateBody)) + require.NoError(t, err) + updateResp.Body.Close() + assert.Equal(t, http.StatusOK, updateResp.StatusCode) + + // Manually set expired allow list entry via database (simulating expired access) + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + db := testutils.SetupPDPDB(t) + if db == nil { + t.Skip("Database connection not available for manual expiration test") + return + } + + // Get the record and manually expire it + var result struct { + AllowList string `gorm:"column:allow_list"` + } + err = db.Table("policy_metadata"). + Where("schema_id = ? AND field_name = ?", schemaID, fieldName). + Select("allow_list"). + Scan(&result).Error + require.NoError(t, err) + + // Update with expired timestamp (yesterday) + expiredTime := time.Now().AddDate(0, 0, -1).Format(time.RFC3339) + expiredJSON := fmt.Sprintf(`{"%s":{"expires_at":"%s","updated_at":"%s"}}`, appID, expiredTime, time.Now().Format(time.RFC3339)) + err = db.Table("policy_metadata"). + Where("schema_id = ? AND field_name = ?", schemaID, fieldName). + Update("allow_list", expiredJSON).Error + require.NoError(t, err) + + // Test policy decision with expired access + decisionReq := PolicyDecisionRequest{ + ApplicationID: appID, + RequiredFields: []PolicyDecisionRequestRecord{ + { + FieldName: fieldName, + SchemaID: schemaID, + }, + }, + } + + decisionBody, err := json.Marshal(decisionReq) + require.NoError(t, err) + + decisionResp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBuffer(decisionBody)) + require.NoError(t, err) + defer decisionResp.Body.Close() + + assert.Equal(t, http.StatusOK, decisionResp.StatusCode, "Should return 200 even with expired access") + + var decisionResponse PolicyDecisionResponse + err = json.NewDecoder(decisionResp.Body).Decode(&decisionResponse) + require.NoError(t, err) + + assert.True(t, decisionResponse.AppAccessExpired, "Access should be expired") + assert.Equal(t, 1, len(decisionResponse.ExpiredFields), "Should have one expired field") + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} diff --git a/tests/integration/policy/policy_test.go b/tests/integration/policy/policy_test.go new file mode 100644 index 00000000..596600e5 --- /dev/null +++ b/tests/integration/policy/policy_test.go @@ -0,0 +1,534 @@ +package policy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/gov-dx-sandbox/tests/integration/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + pdpBaseURL = "http://127.0.0.1:8082" +) + +// generateTestID generates a unique test ID using timestamp +func generateTestID(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().Unix()) +} + +// cleanupPolicyMetadata removes policy metadata from the database +func cleanupPolicyMetadata(t *testing.T, schemaID string) { + cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + if db := testutils.SetupPostgresTestDB(t); db != nil { + db.Exec("DELETE FROM policy_metadata WHERE schema_id = ?", schemaID) + } +} + +// Request/Response types matching PDP API + +type PolicyMetadataCreateRequestRecord struct { + FieldName string `json:"fieldName"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + Source string `json:"source"` + IsOwner bool `json:"isOwner"` + AccessControlType string `json:"accessControlType"` + Owner *string `json:"owner,omitempty"` +} + +type PolicyMetadataCreateRequest struct { + SchemaID string `json:"schemaId"` + Records []PolicyMetadataCreateRequestRecord `json:"records"` +} + +type PolicyMetadataResponse struct { + ID string `json:"id"` + SchemaID string `json:"schemaId"` + FieldName string `json:"fieldName"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + Source string `json:"source"` + IsOwner bool `json:"isOwner"` + AccessControlType string `json:"accessControlType"` + AllowList map[string]interface{} `json:"allowList"` + Owner *string `json:"owner,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type PolicyMetadataCreateResponse struct { + Records []PolicyMetadataResponse `json:"records"` +} + +type AllowListUpdateRequestRecord struct { + FieldName string `json:"fieldName"` + SchemaID string `json:"schemaId"` +} + +type AllowListUpdateRequest struct { + ApplicationID string `json:"applicationId"` + Records []AllowListUpdateRequestRecord `json:"records"` + GrantDuration string `json:"grantDuration"` +} + +type AllowListUpdateResponseRecord struct { + FieldName string `json:"fieldName"` + SchemaID string `json:"schemaId"` + ExpiresAt string `json:"expiresAt"` + UpdatedAt string `json:"updatedAt"` +} + +type AllowListUpdateResponse struct { + Records []AllowListUpdateResponseRecord `json:"records"` +} + +type PolicyDecisionRequestRecord struct { + FieldName string `json:"fieldName"` + SchemaID string `json:"schemaId"` +} + +type PolicyDecisionRequest struct { + ApplicationID string `json:"applicationId"` + RequiredFields []PolicyDecisionRequestRecord `json:"requiredFields"` +} + +type PolicyDecisionResponseFieldRecord struct { + FieldName string `json:"fieldName"` + SchemaID string `json:"schemaId"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + Owner *string `json:"owner,omitempty"` +} + +type PolicyDecisionResponse struct { + AppAuthorized bool `json:"appAuthorized"` + UnauthorizedFields []PolicyDecisionResponseFieldRecord `json:"unauthorizedFields"` + AppAccessExpired bool `json:"appAccessExpired"` + ExpiredFields []PolicyDecisionResponseFieldRecord `json:"expiredFields"` + AppRequiresOwnerConsent bool `json:"appRequiresOwnerConsent"` + ConsentRequiredFields []PolicyDecisionResponseFieldRecord `json:"consentRequiredFields"` +} + +func TestMain(m *testing.M) { + // Wait for PDP service availability + if err := testutils.WaitForService(pdpBaseURL+"/health", 30); err != nil { + fmt.Printf("Policy Decision Point service not available: %v\n", err) + os.Exit(1) + } + + code := m.Run() + os.Exit(code) +} + +// TestPolicy_CreateMetadata tests creating policy metadata via HTTP API +func TestPolicy_CreateMetadata(t *testing.T) { + schemaID := generateTestID("test-schema") + displayName := "Full Name" + description := "The full name of the person" + + req := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + DisplayName: &displayName, + Description: &description, + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Expected 201 Created") + + var createResponse PolicyMetadataCreateResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + require.NoError(t, err) + + assert.Len(t, createResponse.Records, 1, "Should return one record") + assert.Equal(t, schemaID, createResponse.Records[0].SchemaID) + assert.Equal(t, "person.fullName", createResponse.Records[0].FieldName) + assert.Equal(t, "public", createResponse.Records[0].AccessControlType) + assert.True(t, createResponse.Records[0].IsOwner) + + // Cleanup: Remove created policy metadata + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_CreateMetadata_InvalidJSON tests invalid JSON handling +func TestPolicy_CreateMetadata_InvalidJSON(t *testing.T) { + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBufferString("invalid json")) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Expected 400 Bad Request for invalid JSON") +} + +// TestPolicy_CreateMetadata_MissingSchemaID tests validation error for missing schemaId +// The handler validates empty schemaId and returns 400 Bad Request. +func TestPolicy_CreateMetadata_MissingSchemaID(t *testing.T) { + req := PolicyMetadataCreateRequest{ + SchemaID: "", // Missing schemaId + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + defer resp.Body.Close() + + // Handler validates empty schemaId and returns 400 Bad Request + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Expected 400 Bad Request for empty schemaId") + + // Verify error message + var errorResp map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&errorResp) + require.NoError(t, err) + if errorMsg, ok := errorResp["error"].(string); ok { + assert.Contains(t, errorMsg, "schemaId", "Error message should mention schemaId") + } +} + +// TestPolicy_UpdateAllowList tests updating allow list via HTTP API +func TestPolicy_UpdateAllowList(t *testing.T) { + // First, create policy metadata + schemaID := generateTestID("test-schema-allowlist") + req := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + Source: "primary", + IsOwner: true, + AccessControlType: "restricted", + }, + }, + } + + reqBody, err := json.Marshal(req) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Now update allow list + appID := generateTestID("test-app") + updateReq := AllowListUpdateRequest{ + ApplicationID: appID, + Records: []AllowListUpdateRequestRecord{ + { + FieldName: "person.fullName", + SchemaID: schemaID, + }, + }, + GrantDuration: "30d", // Valid grant duration: "30d" or "365d" + } + + updateBody, err := json.Marshal(updateReq) + require.NoError(t, err) + + updateResp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBuffer(updateBody)) + require.NoError(t, err) + defer updateResp.Body.Close() + + // Read response body for debugging + bodyBytes, _ := io.ReadAll(updateResp.Body) + if updateResp.StatusCode != http.StatusOK { + t.Logf("UpdateAllowList failed with status %d, body: %s", updateResp.StatusCode, string(bodyBytes)) + } + assert.Equal(t, http.StatusOK, updateResp.StatusCode, "Expected 200 OK, got %d with body: %s", updateResp.StatusCode, string(bodyBytes)) + + var updateResponse AllowListUpdateResponse + err = json.Unmarshal(bodyBytes, &updateResponse) + require.NoError(t, err, "Failed to decode response: %s", string(bodyBytes)) + + assert.Len(t, updateResponse.Records, 1, "Should return one record, got %d", len(updateResponse.Records)) + assert.Equal(t, "person.fullName", updateResponse.Records[0].FieldName) + assert.Equal(t, schemaID, updateResponse.Records[0].SchemaID) + assert.NotEmpty(t, updateResponse.Records[0].ExpiresAt, "Should have expiration time") + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_UpdateAllowList_InvalidJSON tests invalid JSON handling +func TestPolicy_UpdateAllowList_InvalidJSON(t *testing.T) { + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBufferString("invalid json")) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Expected 400 Bad Request for invalid JSON") +} + +// TestPolicy_GetPolicyDecision tests policy decision via HTTP API +func TestPolicy_GetPolicyDecision(t *testing.T) { + // First, create policy metadata and add to allow list + schemaID := generateTestID("test-schema-decide") + appID := generateTestID("test-app-decide") + + // Create metadata + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Add to allow list + allowListReq := AllowListUpdateRequest{ + ApplicationID: appID, + Records: []AllowListUpdateRequestRecord{ + { + FieldName: "person.fullName", + SchemaID: schemaID, + }, + }, + GrantDuration: "30d", // Valid grant duration: "30d" or "365d" + } + + allowListBody, err := json.Marshal(allowListReq) + require.NoError(t, err) + + allowListResp, err := http.Post(pdpBaseURL+"/api/v1/policy/update-allowlist", "application/json", bytes.NewBuffer(allowListBody)) + require.NoError(t, err) + allowListResp.Body.Close() + assert.Equal(t, http.StatusOK, allowListResp.StatusCode) + + // Now test policy decision + decisionReq := PolicyDecisionRequest{ + ApplicationID: appID, + RequiredFields: []PolicyDecisionRequestRecord{ + { + FieldName: "person.fullName", + SchemaID: schemaID, + }, + }, + } + + decisionBody, err := json.Marshal(decisionReq) + require.NoError(t, err) + + decisionResp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBuffer(decisionBody)) + require.NoError(t, err) + defer decisionResp.Body.Close() + + assert.Equal(t, http.StatusOK, decisionResp.StatusCode, "Expected 200 OK") + + var decisionResponse PolicyDecisionResponse + err = json.NewDecoder(decisionResp.Body).Decode(&decisionResponse) + require.NoError(t, err) + + assert.True(t, decisionResponse.AppAuthorized, "Application should be authorized") + assert.False(t, decisionResponse.AppRequiresOwnerConsent, "Public field should not require consent") + assert.Len(t, decisionResponse.UnauthorizedFields, 0, "Should have no unauthorized fields") + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_GetPolicyDecision_Unauthorized tests policy decision for unauthorized app +func TestPolicy_GetPolicyDecision_Unauthorized(t *testing.T) { + // Create policy metadata but don't add app to allow list + schemaID := generateTestID("test-schema-unauth") + appID := generateTestID("test-app-unauth") + + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + Source: "primary", + IsOwner: true, + AccessControlType: "restricted", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Test policy decision without adding to allow list + decisionReq := PolicyDecisionRequest{ + ApplicationID: appID, + RequiredFields: []PolicyDecisionRequestRecord{ + { + FieldName: "person.fullName", + SchemaID: schemaID, + }, + }, + } + + decisionBody, err := json.Marshal(decisionReq) + require.NoError(t, err) + + decisionResp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBuffer(decisionBody)) + require.NoError(t, err) + defer decisionResp.Body.Close() + + assert.Equal(t, http.StatusOK, decisionResp.StatusCode, "Expected 200 OK") + + var decisionResponse PolicyDecisionResponse + err = json.NewDecoder(decisionResp.Body).Decode(&decisionResponse) + require.NoError(t, err) + + assert.False(t, decisionResponse.AppAuthorized, "Application should not be authorized") + assert.Len(t, decisionResponse.UnauthorizedFields, 1, "Should have one unauthorized field") + assert.Equal(t, "person.fullName", decisionResponse.UnauthorizedFields[0].FieldName) + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} + +// TestPolicy_GetPolicyDecision_InvalidJSON tests invalid JSON handling +func TestPolicy_GetPolicyDecision_InvalidJSON(t *testing.T) { + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBufferString("invalid json")) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Expected 400 Bad Request for invalid JSON") +} + +// TestPolicy_GetPolicyDecision_MissingApplicationID tests validation error +func TestPolicy_GetPolicyDecision_MissingApplicationID(t *testing.T) { + decisionReq := PolicyDecisionRequest{ + ApplicationID: "", // Missing applicationId + RequiredFields: []PolicyDecisionRequestRecord{ + { + FieldName: "person.fullName", + SchemaID: "test-schema", + }, + }, + } + + decisionBody, err := json.Marshal(decisionReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/decide", "application/json", bytes.NewBuffer(decisionBody)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Expected 400 Bad Request for missing applicationId") +} + +// TestPolicy_UpdateMetadata tests updating existing policy metadata +func TestPolicy_UpdateMetadata(t *testing.T) { + schemaID := generateTestID("test-schema-update") + displayName1 := "Full Name" + displayName2 := "Complete Name" + + // Create initial metadata + createReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + DisplayName: &displayName1, + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + createBody, err := json.Marshal(createReq) + require.NoError(t, err) + + resp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(createBody)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Update metadata + updateReq := PolicyMetadataCreateRequest{ + SchemaID: schemaID, + Records: []PolicyMetadataCreateRequestRecord{ + { + FieldName: "person.fullName", + DisplayName: &displayName2, + Source: "primary", + IsOwner: true, + AccessControlType: "public", + }, + }, + } + + updateBody, err := json.Marshal(updateReq) + require.NoError(t, err) + + updateResp, err := http.Post(pdpBaseURL+"/api/v1/policy/metadata", "application/json", bytes.NewBuffer(updateBody)) + require.NoError(t, err) + defer updateResp.Body.Close() + + assert.Equal(t, http.StatusCreated, updateResp.StatusCode, "Expected 201 Created") + + var updateResponse PolicyMetadataCreateResponse + err = json.NewDecoder(updateResp.Body).Decode(&updateResponse) + require.NoError(t, err) + + assert.Len(t, updateResponse.Records, 1) + assert.Equal(t, displayName2, *updateResponse.Records[0].DisplayName, "Display name should be updated") + + // Cleanup + t.Cleanup(func() { + cleanupPolicyMetadata(t, schemaID) + }) +} diff --git a/tests/integration/testutils/db.go b/tests/integration/testutils/db.go index 58e34bc1..19baf533 100644 --- a/tests/integration/testutils/db.go +++ b/tests/integration/testutils/db.go @@ -119,3 +119,126 @@ func GetPolicyMetadataCount(t *testing.T, db *gorm.DB) int64 { return count } + +// DBConfig holds database configuration for test setup +type DBConfig struct { + Port string + Database string + Password string +} + +// WithTestDBEnv sets up environment variables for a specific test database and returns a cleanup function. +// This helper eliminates code duplication across test files. +// +// Example: +// +// cleanup := testutils.WithTestDBEnv(t, testutils.DBConfig{ +// Port: "5433", +// Database: "policy_db", +// Password: "password", +// }) +// defer cleanup() +func WithTestDBEnv(t *testing.T, config DBConfig) func() { + originalPort := os.Getenv("TEST_DB_PORT") + originalDB := os.Getenv("TEST_DB_DATABASE") + originalPassword := os.Getenv("TEST_DB_PASSWORD") + + os.Setenv("TEST_DB_PORT", config.Port) + os.Setenv("TEST_DB_DATABASE", config.Database) + if config.Password != "" { + if originalPassword == "" { + os.Setenv("TEST_DB_PASSWORD", config.Password) + } + } + + return func() { + if originalPort != "" { + os.Setenv("TEST_DB_PORT", originalPort) + } else { + os.Unsetenv("TEST_DB_PORT") + } + if originalDB != "" { + os.Setenv("TEST_DB_DATABASE", originalDB) + } else { + os.Unsetenv("TEST_DB_DATABASE") + } + if originalPassword == "" && config.Password != "" { + os.Unsetenv("TEST_DB_PASSWORD") + } + } +} + +// SetupConsentDB creates a PostgreSQL connection to the Consent Engine test database. +// This is a convenience wrapper around SetupPostgresTestDB with Consent Engine defaults. +// Returns nil if connection cannot be established (test will be skipped). +func SetupConsentDB(t *testing.T) *gorm.DB { + cleanup := WithTestDBEnv(t, DBConfig{ + Port: "5434", + Database: "consent_db", + Password: "password", + }) + defer cleanup() + + return SetupPostgresTestDB(t) +} + +// CleanupConsentRecord removes a consent record from the database. +// This is a helper function to reduce code duplication in consent tests. +func CleanupConsentRecord(t *testing.T, consentID string) { + if consentID == "" { + return + } + + db := SetupConsentDB(t) + if db == nil { + t.Logf("Skipping cleanup for consent %s: database not available", consentID) + return + } + + if err := db.Exec("DELETE FROM consent_records WHERE consent_id = ?", consentID).Error; err != nil { + t.Logf("Warning: failed to cleanup consent %s: %v", consentID, err) + } else { + t.Logf("Cleaned up consent: %s", consentID) + } +} + +// SetupPDPDB creates a PostgreSQL connection to the Policy Decision Point test database. +// This is a convenience wrapper around SetupPostgresTestDB with PDP defaults. +func SetupPDPDB(t *testing.T) *gorm.DB { + cleanup := WithTestDBEnv(t, DBConfig{ + Port: "5433", + Database: "policy_db", + Password: "password", + }) + defer cleanup() + + return SetupPostgresTestDB(t) +} + +// WithTestDBEnvFull sets up multiple environment variables for test database configuration. +// This is used for tests that need to override multiple DB connection parameters. +// +// Example: +// +// cleanup := testutils.WithTestDBEnvFull(t, map[string]string{ +// "TEST_DB_HOST": "invalid-host", +// "TEST_DB_PORT": "5432", +// }) +// defer cleanup() +func WithTestDBEnvFull(t *testing.T, envVars map[string]string) func() { + originals := make(map[string]string) + for key := range envVars { + originals[key] = os.Getenv(key) + os.Setenv(key, envVars[key]) + } + + return func() { + for key, originalValue := range originals { + if originalValue != "" { + os.Setenv(key, originalValue) + } else { + os.Unsetenv(key) + } + } + } +}