From cbdf6185b6829250dca9509c8d0200e5d583ee92 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Mon, 23 Feb 2026 13:25:05 +1030 Subject: [PATCH] remove junk --- SEED_DATA_OIDC_EXTENSION.md | 135 --- TOTP_TEST_PROPOSAL.md | 171 --- manual_totp_reset.md | 47 - ...7c5_add_application_wide_external_auth_.py | 69 -- migrations/versions/a4d4a17a5d15_.py | 86 -- oidc_test.sh | 102 -- test_oauth_without_org.sh | 70 -- test_totp_full.py | 501 -------- tests/__init__.py | 1 - tests/conftest.py | 375 ------ tests/integration/__init__.py | 1 - tests/integration/test_auth_flow.py | 107 -- tests/integration/test_external_auth_flow.py | 696 ----------- tests/integration/test_mfa_compliance.py | 933 --------------- tests/integration/test_oidc_flow.py | 1028 ----------------- tests/unit/__init__.py | 1 - tests/unit/test_mfa_policy_models.py | 295 ----- tests/unit/test_models.py | 76 -- tests/unit/test_services/__init__.py | 1 - tests/unit/test_services/test_auth_service.py | 102 -- .../test_external_auth_service.py | 698 ----------- .../test_services/test_mfa_policy_service.py | 476 -------- .../test_services/test_oauth_flow_service.py | 533 --------- tests/unit/test_services/test_totp_service.py | 285 ----- 24 files changed, 6789 deletions(-) delete mode 100644 SEED_DATA_OIDC_EXTENSION.md delete mode 100644 TOTP_TEST_PROPOSAL.md delete mode 100644 manual_totp_reset.md delete mode 100644 migrations/versions/4edc2fce47c5_add_application_wide_external_auth_.py delete mode 100644 migrations/versions/a4d4a17a5d15_.py delete mode 100644 oidc_test.sh delete mode 100755 test_oauth_without_org.sh delete mode 100644 test_totp_full.py delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/integration/__init__.py delete mode 100644 tests/integration/test_auth_flow.py delete mode 100644 tests/integration/test_external_auth_flow.py delete mode 100644 tests/integration/test_mfa_compliance.py delete mode 100644 tests/integration/test_oidc_flow.py delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/test_mfa_policy_models.py delete mode 100644 tests/unit/test_models.py delete mode 100644 tests/unit/test_services/__init__.py delete mode 100644 tests/unit/test_services/test_auth_service.py delete mode 100644 tests/unit/test_services/test_external_auth_service.py delete mode 100644 tests/unit/test_services/test_mfa_policy_service.py delete mode 100644 tests/unit/test_services/test_oauth_flow_service.py delete mode 100644 tests/unit/test_services/test_totp_service.py diff --git a/SEED_DATA_OIDC_EXTENSION.md b/SEED_DATA_OIDC_EXTENSION.md deleted file mode 100644 index d4b8fe9..0000000 --- a/SEED_DATA_OIDC_EXTENSION.md +++ /dev/null @@ -1,135 +0,0 @@ -# OIDC Extension to Seed Data Script - -## Summary - -Extended [`scripts/seed_data.py`](scripts/seed_data.py) to include OIDC client seeding functionality. - -## Changes Made - -### 1. Added Imports -- `import secrets` - For generating secure random values -- `import hashlib` - For hashing client secrets -- `from app.models.oidc_client import OIDCClient` - OIDC client model - -### 2. New Helper Function: `create_or_get_oidc_client()` -Creates OIDC clients with proper configuration or returns existing ones. Features: -- Checks for existing clients by `client_id` -- Hashes client secrets using SHA256 -- Supports all OIDC client configuration options -- Proper error handling and logging - -### 3. New Seed Step: Step 5 - Create OIDC Clients - -Added 4 OIDC clients across the 3 seeded organizations: - -#### Acme Corporation (2 clients) -1. **Acme Internal Portal** (`acme-portal-001`) - - Confidential client - - Grant types: authorization_code, refresh_token - - Scopes: openid, profile, email, offline_access - - PKCE required - - Redirect URIs for production and localhost - -2. **Acme Mobile App** (`acme-mobile-001`) - - Public client (mobile app) - - Shorter token lifetimes for security - - PKCE required - - Custom URL scheme for mobile redirect - -#### Tech Startup Inc (1 client) -3. **Tech Startup Dashboard** (`tech-dashboard-001`) - - Confidential client - - Standard OIDC configuration - - PKCE required - -#### Data Systems Inc (1 client) -4. **Data Systems API Client** (`data-api-001`) - - Confidential server-to-server client - - Additional grant type: client_credentials - - Custom scopes: api:read, api:write - - PKCE not required (server-to-server) - -## OIDC Client Test Credentials - -All clients are configured with test credentials for development: - -| Client | Client ID | Client Secret | -|--------|-----------|---------------| -| Acme Portal | `acme-portal-001` | `acme_secret_portal_2024` | -| Acme Mobile | `acme-mobile-001` | `acme_secret_mobile_2024` | -| Tech Dashboard | `tech-dashboard-001` | `tech_secret_dashboard_2024` | -| Data API | `data-api-001` | `data_secret_api_2024` | - -## Enhanced Summary Output - -The seed script now displays: -- Total count of OIDC clients created -- Detailed information for each client including: - - Client name and ID - - Organization - - Configured grant types - - Configured scopes - - Number of redirect URIs -- Complete test credentials table - -## Example Output - -``` -[Step 5] Creating OIDC Clients... - - Acme Corporation OIDC Clients: - → Created OIDC client: Acme Internal Portal - → Created OIDC client: Acme Mobile App - - Tech Startup OIDC Clients: - → Created OIDC client: Tech Startup Dashboard - - Data Systems OIDC Clients: - → Created OIDC client: Data Systems API Client - - Created 4 OIDC clients - -============================================================ -Seed Complete! -============================================================ - -šŸ“Š Summary: - Organizations: 3 - Admin Users: 2 - Regular Users: 9 - OIDC Clients: 4 - -šŸ” OIDC Clients: - Acme Internal Portal - Client ID: acme-portal-001 - Organization: Acme Corporation - Grant Types: authorization_code, refresh_token - Scopes: openid, profile, email, offline_access - Redirect URIs: 2 configured - ... -``` - -## Features - -- **Idempotent**: Running the script multiple times won't create duplicate clients -- **Comprehensive**: Creates diverse client types (confidential, public, server-to-server) -- **Production-ready**: Includes proper secret hashing and security configurations -- **Developer-friendly**: Includes localhost URLs and clear test credentials -- **Well-documented**: Clear console output showing what was created - -## Usage - -Run the seed script as usual: - -```bash -python scripts/seed_data.py -``` - -The OIDC clients will be automatically created along with users and organizations. - -## Security Notes - -- Client secrets are hashed using SHA256 before storage -- Test credentials are clearly marked and should **not** be used in production -- PKCE is enabled by default for web and mobile clients -- Token lifetimes are configured appropriately for each client type diff --git a/TOTP_TEST_PROPOSAL.md b/TOTP_TEST_PROPOSAL.md deleted file mode 100644 index 9f7db18..0000000 --- a/TOTP_TEST_PROPOSAL.md +++ /dev/null @@ -1,171 +0,0 @@ -# TOTP End-to-End Test Proposal - -## Test Objective -Test ALL aspects of TOTP functionality regardless of current state (TOTP enabled or disabled). - -## Test Flow - -### Scenario A: TOTP Currently Enabled (Bob already enrolled) - -1. **Login** with email/password - - Response: `requires_totp: true` - -2. **Get Secret from DB** (or use environment variable) - - Since secret is encrypted/hashed in DB, we need to either: - - Store it in environment/file from previous enrollment, OR - - User provides it as input, OR - - Use backup code from previous enrollment - -3. **Generate TOTP Code** using stored secret/backup code - -4. **Verify TOTP** to complete login - - Endpoint: `/auth/totp/verify` - - Get auth_token - -5. **Check TOTP Status** - - Endpoint: `/auth/totp/status` - - Confirm: `totp_enabled: true` - -6. **Disable TOTP** - - Endpoint: `/auth/totp/disable` - - Provide password - -7. **Logout** - -8. **Continue to Scenario B steps 2-14** - -### Scenario B: TOTP Currently Disabled (or after completing Scenario A) - -1. **Login** with email/password - - Response: `token` (no TOTP required) - -2. **Check TOTP Status** - - Endpoint: `/auth/totp/status` - - Confirm: `totp_enabled: false` - -3. **Enroll in TOTP** - - Endpoint: `/auth/totp/enroll` - - Store: secret, backup_codes, provisioning_uri, qr_code - -4. **Generate TOTP Code** from new secret - - Use timezone-aware UTC - -5. **Verify Enrollment** - - Endpoint: `/auth/totp/verify-enrollment` - - Provide generated code - -6. **Check TOTP Status Again** - - Confirm: `totp_enabled: true` - - Confirm: `backup_codes_remaining: 10` - - Confirm: `verified_at` is set - -7. **Logout** - -8. **Login** with email/password - - Response: `requires_totp: true` - -9. **Generate TOTP Code** from stored secret - -10. **Verify TOTP** to complete login - - Endpoint: `/auth/totp/verify` - - Get auth_token - -11. **Confirm Logged In** - - Endpoint: `/auth/me` - - Verify user data returned - -12. **Test Backup Code** (new login) - - Logout - - Login with email/password - - Use backup code instead of TOTP - - Endpoint: `/auth/totp/verify` with `is_backup_code: true` - -13. **Check Backup Codes Remaining** - - Should be 9 (one consumed) - -14. **Regenerate Backup Codes** - - Endpoint: `/auth/totp/regenerate-backup-codes` - - Provide password - - Get new set of 10 codes - -## Implementation Strategy - -### Secret Persistence Between Test Runs - -**Option 1: Environment Variable** (Recommended) -```python -import os - -# Save secret after first successful enrollment -SECRET_FILE = ".totp_test_secret" - -if os.path.exists(SECRET_FILE): - with open(SECRET_FILE) as f: - data = json.load(f) - known_secret = data.get("secret") - known_backup_codes = data.get("backup_codes", []) -else: - known_secret = None - known_backup_codes = [] - -# After enrollment, save for next run -with open(SECRET_FILE, 'w') as f: - json.dump({ - "secret": new_secret, - "backup_codes": new_backup_codes - }, f) -``` - -**Option 2: Test Database State** -- Include SQL query to fetch secret from DB (if stored in plain text for testing) -- Or decrypt if encrypted - -**Option 3: Manual Input** -- Prompt user for secret/backup code if TOTP already enabled -- Less automated but more flexible - -## Expected Assertions - -1. āœ… Login without TOTP works when disabled -2. āœ… Enrollment generates secret, QR code, backup codes -3. āœ… Enrollment verification accepts valid TOTP code -4. āœ… TOTP status shows enabled after verification -5. āœ… Login requires TOTP when enabled -6. āœ… TOTP verification works during login -7. āœ… Backup code works for authentication -8. āœ… Backup codes decrement when used -9. āœ… Backup code regeneration works -10. āœ… TOTP disable works with correct password -11. āœ… Login works without TOTP after disabling - -## Test Data Management - -Store in `.totp_test_data.json` (gitignored): -```json -{ - "user": "bob@acme-corp.com", - "secret": "BWAQAP55...", - "backup_codes": ["code1", "code2", ...], - "enrollment_date": "2026-01-14T03:12:00Z", - "last_test_run": "2026-01-14T03:15:00Z" -} -``` - -## Error Handling - -- Connection errors → clear message about server not running -- 401 errors → check if token/credentials are correct -- TOTP code failures → check time synchronization -- Backup code failures → check if already used - -## Success Criteria - -Test passes when: -1. All 14 steps complete without errors -2. All assertions pass -3. Test can run multiple times (idempotent) -4. Works from both initial states (TOTP enabled/disabled) - ---- - -**Please review this proposal. Once approved, I'll implement it.** diff --git a/manual_totp_reset.md b/manual_totp_reset.md deleted file mode 100644 index 790a3cf..0000000 --- a/manual_totp_reset.md +++ /dev/null @@ -1,47 +0,0 @@ -# Manual TOTP Reset for Testing - -Since Bob has TOTP enabled, you have two options to run the full test: - -## Option 1: Restart Flask Server (Easiest) -The Flask server running on port 8888 uses an in-memory SQLite database. -Simply restart it to clear all data: - -```bash -# Stop the server (Ctrl+C in the terminal) -# Then restart it -cd gatehouse-api -.venv/bin/flask run --debug --port 8888 -``` - -Then run the test: -```bash -.venv/bin/python test_totp_full.py -``` - -## Option 2: Use the TOTP Secret - -If you have the secret from the previous enrollment (check `.totp_test_data.json` if it exists): - -1. Edit `test_totp_full.py` -2. Update the `test_data` initialization: -```python -test_data = { - "secret": "YOUR_SECRET_HERE", # From previous enrollment - "backup_codes": ["CODE1", "CODE2", ...], # From previous enrollment - "last_run": None -} -``` - -3. Run the test - -## Option 3: Database Direct Access (if file-based DB) - -If using PostgreSQL or file-based SQLite: - -```sql -DELETE FROM authentication_methods -WHERE user_id = (SELECT id FROM users WHERE email = 'bob@acme-corp.com') - AND method_type = 'totp'; -``` - -The test will then run through the complete flow and save the new secret/codes to `.totp_test_data.json` for subsequent runs. diff --git a/migrations/versions/4edc2fce47c5_add_application_wide_external_auth_.py b/migrations/versions/4edc2fce47c5_add_application_wide_external_auth_.py deleted file mode 100644 index 38f2cc1..0000000 --- a/migrations/versions/4edc2fce47c5_add_application_wide_external_auth_.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Add application-wide external auth provider config tables - -Revision ID: 4edc2fce47c5 -Revises: a4d4a17a5d15 -Create Date: 2026-01-20 16:02:34.196815 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '4edc2fce47c5' -down_revision = 'a4d4a17a5d15' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('application_provider_configs', - sa.Column('provider_type', sa.String(length=50), nullable=False), - sa.Column('client_id', sa.String(length=255), nullable=False), - sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True), - sa.Column('is_enabled', sa.Boolean(), nullable=False), - sa.Column('default_redirect_url', sa.String(length=2048), nullable=True), - sa.Column('additional_config', sa.JSON(), nullable=True), - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_index(op.f('ix_application_provider_configs_provider_type'), 'application_provider_configs', ['provider_type'], unique=True) - op.create_table('organization_provider_overrides', - sa.Column('organization_id', sa.String(length=36), nullable=False), - sa.Column('provider_type', sa.String(length=50), nullable=False), - sa.Column('client_id', sa.String(length=255), nullable=True), - sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True), - sa.Column('is_enabled', sa.Boolean(), nullable=False), - sa.Column('redirect_url_override', sa.String(length=2048), nullable=True), - sa.Column('additional_config', sa.JSON(), nullable=True), - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id'), - sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type') - ) - op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False) - op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False) - op.add_column('oauth_states', sa.Column('return_url', sa.String(length=2048), nullable=True)) - op.drop_index(op.f('ix_oauth_states_user_id'), table_name='oauth_states') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_index(op.f('ix_oauth_states_user_id'), 'oauth_states', ['user_id'], unique=False) - op.drop_column('oauth_states', 'return_url') - op.drop_index(op.f('ix_organization_provider_overrides_provider_type'), table_name='organization_provider_overrides') - op.drop_index(op.f('ix_organization_provider_overrides_organization_id'), table_name='organization_provider_overrides') - op.drop_table('organization_provider_overrides') - op.drop_index(op.f('ix_application_provider_configs_provider_type'), table_name='application_provider_configs') - op.drop_table('application_provider_configs') - # ### end Alembic commands ### diff --git a/migrations/versions/a4d4a17a5d15_.py b/migrations/versions/a4d4a17a5d15_.py deleted file mode 100644 index 31014aa..0000000 --- a/migrations/versions/a4d4a17a5d15_.py +++ /dev/null @@ -1,86 +0,0 @@ -"""empty message - -Revision ID: a4d4a17a5d15 -Revises: 004 -Create Date: 2026-01-20 14:30:36.898886 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'a4d4a17a5d15' -down_revision = '004' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('external_provider_configs', - sa.Column('organization_id', sa.String(length=36), nullable=False), - sa.Column('provider_type', sa.String(length=50), nullable=False), - sa.Column('client_id', sa.String(length=255), nullable=False), - sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True), - sa.Column('auth_url', sa.String(length=2048), nullable=False), - sa.Column('token_url', sa.String(length=2048), nullable=False), - sa.Column('userinfo_url', sa.String(length=2048), nullable=True), - sa.Column('jwks_url', sa.String(length=2048), nullable=True), - sa.Column('scopes', sa.JSON(), nullable=False), - sa.Column('redirect_uris', sa.JSON(), nullable=False), - sa.Column('settings', sa.JSON(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id'), - sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type') - ) - op.create_index('idx_provider_config_org', 'external_provider_configs', ['organization_id', 'provider_type'], unique=False) - op.create_index(op.f('ix_external_provider_configs_organization_id'), 'external_provider_configs', ['organization_id'], unique=False) - op.create_index(op.f('ix_external_provider_configs_provider_type'), 'external_provider_configs', ['provider_type'], unique=False) - op.create_table('oauth_states', - sa.Column('state', sa.String(length=64), nullable=False), - sa.Column('flow_type', sa.String(length=50), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=True), - sa.Column('organization_id', sa.String(length=36), nullable=True), - sa.Column('provider_type', sa.String(length=50), nullable=False), - sa.Column('nonce', sa.String(length=128), nullable=True), - sa.Column('code_verifier', sa.String(length=128), nullable=True), - sa.Column('code_challenge', sa.String(length=128), nullable=True), - sa.Column('redirect_uri', sa.String(length=2048), nullable=True), - sa.Column('extra_data', sa.JSON(), nullable=True), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('used', sa.Boolean(), nullable=False), - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') - ) - op.create_index(op.f('ix_oauth_states_expires_at'), 'oauth_states', ['expires_at'], unique=False) - op.create_index(op.f('ix_oauth_states_organization_id'), 'oauth_states', ['organization_id'], unique=False) - op.create_index(op.f('ix_oauth_states_state'), 'oauth_states', ['state'], unique=True) - op.create_index(op.f('ix_oauth_states_user_id'), 'oauth_states', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_oauth_states_user_id'), table_name='oauth_states') - op.drop_index(op.f('ix_oauth_states_state'), table_name='oauth_states') - op.drop_index(op.f('ix_oauth_states_organization_id'), table_name='oauth_states') - op.drop_index(op.f('ix_oauth_states_expires_at'), table_name='oauth_states') - op.drop_table('oauth_states') - op.drop_index(op.f('ix_external_provider_configs_provider_type'), table_name='external_provider_configs') - op.drop_index(op.f('ix_external_provider_configs_organization_id'), table_name='external_provider_configs') - op.drop_index('idx_provider_config_org', table_name='external_provider_configs') - op.drop_table('external_provider_configs') - # ### end Alembic commands ### diff --git a/oidc_test.sh b/oidc_test.sh deleted file mode 100644 index f5abb1a..0000000 --- a/oidc_test.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ISSUER="https://oidctest.wsweet.org" -CLIENT_ID="secret" -CLIENT_SECRET="tardis" -REDIRECT_URI="http://127.0.0.1:5556/callback" -SCOPE="openid profile email offline_access" - -# --------------------------- -# Discover OIDC endpoints -# --------------------------- -DISCOVERY=$(curl -s "$ISSUER/.well-known/openid-configuration") - -AUTH_ENDPOINT=$(echo "$DISCOVERY" | jq -r .authorization_endpoint) -TOKEN_ENDPOINT=$(echo "$DISCOVERY" | jq -r .token_endpoint) -USERINFO_ENDPOINT=$(echo "$DISCOVERY" | jq -r .userinfo_endpoint) - -echo "Auth endpoint : $AUTH_ENDPOINT" -echo "Token endpoint: $TOKEN_ENDPOINT" -echo - -# --------------------------- -# PKCE -# --------------------------- -CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/') -CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=+/' | tr '/+' '_-') - -STATE=$(openssl rand -hex 16) -NONCE=$(openssl rand -hex 16) - -# --------------------------- -# Build auth URL -# --------------------------- -AUTH_URL="$AUTH_ENDPOINT?response_type=code\ -&client_id=$CLIENT_ID\ -&redirect_uri=$(printf '%s' "$REDIRECT_URI" | jq -s -R -r @uri)\ -&scope=$(printf '%s' "$SCOPE" | jq -s -R -r @uri)\ -&state=$STATE\ -&nonce=$NONCE\ -&code_challenge=$CODE_CHALLENGE\ -&code_challenge_method=S256" - -echo "Open this URL in a browser:" -echo -echo "$AUTH_URL" -echo -echo "After login you will be redirected to:" -echo "$REDIRECT_URI?code=XXXX&state=YYYY" -echo -read -p "Paste the full redirect URL: " REDIRECT - -CODE=$(echo "$REDIRECT" | sed -n 's/.*code=\([^&]*\).*/\1/p') -RETURNED_STATE=$(echo "$REDIRECT" | sed -n 's/.*state=\([^&]*\).*/\1/p') - -if [ "$RETURNED_STATE" != "$STATE" ]; then - echo "STATE MISMATCH" - exit 1 -fi - -# --------------------------- -# Exchange code for tokens -# --------------------------- -TOKENS=$(curl -s -X POST "$TOKEN_ENDPOINT" \ - -u "$CLIENT_ID:$CLIENT_SECRET" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=authorization_code" \ - -d "code=$CODE" \ - -d "redirect_uri=$REDIRECT_URI" \ - -d "code_verifier=$CODE_VERIFIER") - -echo -echo "Token response:" -echo "$TOKENS" | jq . - -ACCESS_TOKEN=$(echo "$TOKENS" | jq -r .access_token) -ID_TOKEN=$(echo "$TOKENS" | jq -r .id_token) - -# --------------------------- -# JWT decode function -# --------------------------- -decode() { - echo "$1" | awk -F. '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq . -} - -echo -echo "================ ID TOKEN ================" -decode "$ID_TOKEN" - -echo -echo "============== ACCESS TOKEN ==============" -decode "$ACCESS_TOKEN" - -# --------------------------- -# Userinfo (optional) -# --------------------------- -if [ "$USERINFO_ENDPOINT" != "null" ]; then - echo - echo "=============== USERINFO =================" - curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_ENDPOINT" | jq . -fi - diff --git a/test_oauth_without_org.sh b/test_oauth_without_org.sh deleted file mode 100755 index fdf4d76..0000000 --- a/test_oauth_without_org.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash - -# Test script to verify OAuth endpoints work without organization_id -# This tests the fix for the "Google OAuth is not configured for this organization" error - -API_BASE="http://localhost:5001/api/v1" - -echo "=== Testing OAuth Authorization Endpoint (without organization_id) ===" -echo "" -echo "1. Initiating Google OAuth login flow (NO organization_id)..." -RESPONSE=$(curl -s -X GET "${API_BASE}/auth/external/google/authorize?flow=login") -echo "Response: $RESPONSE" -echo "" - -# Check if we get an authorization URL -if echo "$RESPONSE" | grep -q "authorization_url"; then - echo "āœ… SUCCESS: Got authorization URL without requiring organization_id" - AUTH_URL=$(echo "$RESPONSE" | jq -r '.data.authorization_url') - STATE=$(echo "$RESPONSE" | jq -r '.data.state') - echo "Authorization URL: $AUTH_URL" - echo "State: $STATE" -else - echo "āŒ FAILED: Did not get authorization URL" - echo "Error: $(echo "$RESPONSE" | jq -r '.message')" -fi - -echo "" -echo "=== Testing with organization_id hint (should still work) ===" -echo "" -echo "2. Initiating Google OAuth login flow (WITH organization_id hint)..." -# You'll need to replace this with an actual organization ID from your database -ORG_ID="test-org-id" -RESPONSE=$(curl -s -X GET "${API_BASE}/auth/external/google/authorize?flow=login&organization_id=${ORG_ID}") -echo "Response: $RESPONSE" -echo "" - -if echo "$RESPONSE" | grep -q "authorization_url"; then - echo "āœ… SUCCESS: OAuth works with organization_id hint (backward compatible)" -else - echo "āš ļø Note: This may fail if the organization ID doesn't exist or if app-level config is not set" -fi - -echo "" -echo "=== Testing Register Flow ===" -echo "" -echo "3. Initiating Google OAuth register flow (NO organization_id)..." -RESPONSE=$(curl -s -X GET "${API_BASE}/auth/external/google/authorize?flow=register") -echo "Response: $RESPONSE" -echo "" - -if echo "$RESPONSE" | grep -q "authorization_url"; then - echo "āœ… SUCCESS: Register flow works without organization_id" -else - echo "āŒ FAILED: Register flow did not work" - echo "Error: $(echo "$RESPONSE" | jq -r '.message')" -fi - -echo "" -echo "=== Summary ===" -echo "" -echo "The key fix addresses the error:" -echo " 'Google OAuth is not configured for this organization'" -echo "" -echo "Now OAuth flows work at the APPLICATION level, not requiring" -echo "an organization context during initial authentication." -echo "" -echo "After OAuth callback:" -echo " - Single org user → Automatic login" -echo " - Multi org user → Organization selection UI" -echo " - New user → Organization creation/selection UI" diff --git a/test_totp_full.py b/test_totp_full.py deleted file mode 100644 index a745654..0000000 --- a/test_totp_full.py +++ /dev/null @@ -1,501 +0,0 @@ -#!/usr/bin/env python3 -""" -COMPREHENSIVE TOTP END-TO-END FUNCTIONAL TEST -Tests all aspects of TOTP functionality regardless of current state. - -Based on approved proposal in TOTP_TEST_PROPOSAL.md -""" -import requests -import pyotp -import json -import sys -import os -from datetime import datetime, timezone - -# Configuration -BASE_URL = "http://localhost:8888/api/v1" -CREDENTIALS = { - "email": "bob@acme-corp.com", - "password": "UserPass123!" -} -DATA_FILE = ".totp_test_data.json" - -# Test state -test_data = { - "secret": None, - "backup_codes": [], - "last_run": None -} - -def load_test_data(): - """Load test data from previous run.""" - global test_data - if os.path.exists(DATA_FILE): - with open(DATA_FILE, 'r') as f: - test_data = json.load(f) - print(f"šŸ“‚ Loaded test data from {DATA_FILE}") - print(f" Secret: {test_data['secret'][:20] if test_data['secret'] else 'None'}...") - print(f" Backup codes: {len(test_data.get('backup_codes', []))}") - else: - print(f"šŸ“‚ No previous test data found") - -def save_test_data(): - """Save test data for next run.""" - test_data['last_run'] = datetime.now(timezone.utc).isoformat() - with open(DATA_FILE, 'w') as f: - json.dump(test_data, f, indent=2) - print(f"\nšŸ’¾ Saved test data to {DATA_FILE}") - -def print_section(step, title): - """Print test section header.""" - print(f"\n{'='*70}") - print(f"[STEP {step}] {title}") - print('='*70) - -def main(): - """Run comprehensive TOTP test.""" - - print("\n" + "="*70) - print("COMPREHENSIVE TOTP END-TO-END TEST") - print(f"User: {CREDENTIALS['email']}") - print(f"Server: {BASE_URL}") - print(f"Time: {datetime.now(timezone.utc).isoformat()}") - print("="*70) - - load_test_data() - - session = requests.Session() - auth_token = None - totp = None - step = 0 - - try: - # ==================== PHASE 1: INITIAL LOGIN ==================== - - step += 1 - print_section(step, "Initial Login") - - login_response = session.post(f"{BASE_URL}/auth/login", json=CREDENTIALS) - - if login_response.status_code != 200: - print(f"āŒ Login failed: {login_response.status_code}") - print(json.dumps(login_response.json(), indent=2)) - return False - - login_data = login_response.json() - - # Check if TOTP is required - totp_required = login_data.get("data", {}).get("requires_totp", False) - - if totp_required: - print("āš ļø TOTP is ENABLED - login requires verification") - - # We need either saved secret or backup code - if test_data.get('secret'): - print("ā„¹ļø Using saved secret to generate TOTP code") - totp = pyotp.TOTP(test_data['secret']) - utc_now = datetime.now(timezone.utc) - code = totp.at(utc_now) - print(f" Generated code: {code}") - print(f" At time: {utc_now.isoformat()}") - - verify_response = session.post( - f"{BASE_URL}/auth/totp/verify", - json={"code": code} - ) - - if verify_response.status_code != 200: - print("āŒ TOTP code verification failed") - print(" Trying backup code...") - - if test_data.get('backup_codes'): - # Try first unused backup code - for backup_code in test_data['backup_codes']: - verify_response = session.post( - f"{BASE_URL}/auth/totp/verify", - json={"code": backup_code, "is_backup_code": True} - ) - if verify_response.status_code == 200: - print(f"āœ… Authenticated with backup code: {backup_code}") - # Remove used code - test_data['backup_codes'].remove(backup_code) - break - else: - print("āŒ All backup codes failed") - print("\nPlease manually delete Bob's TOTP from database:") - print("DELETE FROM authentication_methods WHERE user_id = (SELECT id FROM users WHERE email = 'bob@acme-corp.com') AND method_type = 'totp';") - return False - else: - print("āŒ No backup codes available") - return False - - auth_token = verify_response.json()["data"]["token"] - print("āœ… Logged in with TOTP verification") - - elif test_data.get('backup_codes'): - print("ā„¹ļø Using backup code to authenticate") - - for backup_code in test_data['backup_codes']: - verify_response = session.post( - f"{BASE_URL}/auth/totp/verify", - json={"code": backup_code, "is_backup_code": True} - ) - if verify_response.status_code == 200: - auth_token = verify_response.json()["data"]["token"] - print(f"āœ… Authenticated with backup code: {backup_code}") - test_data['backup_codes'].remove(backup_code) - break - else: - print("āŒ No valid backup codes") - return False - else: - print("āŒ TOTP enabled but no secret or backup codes available") - print("\nPlease manually delete Bob's TOTP from database:") - print("DELETE FROM authentication_methods WHERE user_id = (SELECT id FROM users WHERE email = 'bob@acme-corp.com') AND method_type = 'totp';") - return False - else: - auth_token = login_data["data"]["token"] - print("āœ… Logged in (TOTP not required)") - - # ==================== PHASE 2: CHECK STATUS AND DISABLE IF ENABLED ==================== - - step += 1 - print_section(step, "Check TOTP Status") - - status_response = session.get( - f"{BASE_URL}/auth/totp/status", - headers={"Authorization": f"Bearer {auth_token}"} - ) - - if status_response.status_code != 200: - print("āŒ Failed to get TOTP status") - return False - - status_data = status_response.json()["data"] - print(f"TOTP Enabled: {status_data['totp_enabled']}") - print(f"Verified At: {status_data.get('verified_at', 'N/A')}") - print(f"Backup Codes Remaining: {status_data['backup_codes_remaining']}") - - # If TOTP is enabled, disable it - if status_data['totp_enabled']: - step += 1 - print_section(step, "Disable TOTP") - - disable_response = session.delete( - f"{BASE_URL}/auth/totp/disable", - headers={"Authorization": f"Bearer {auth_token}"}, - json={"password": CREDENTIALS["password"]} - ) - - if disable_response.status_code != 200: - print("āŒ Failed to disable TOTP") - print(json.dumps(disable_response.json(), indent=2)) - return False - - print("āœ… TOTP disabled") - - # Clear saved secret/codes since we're starting fresh - test_data['secret'] = None - test_data['backup_codes'] = [] - else: - print("ā„¹ļø TOTP already disabled, skipping disable step") - - # ==================== PHASE 3: LOGOUT AND RE-LOGIN ==================== - - step += 1 - print_section(step, "Logout") - - logout_response = session.post( - f"{BASE_URL}/auth/logout", - headers={"Authorization": f"Bearer {auth_token}"} - ) - print(f"āœ… Logged out (status: {logout_response.status_code})") - - step += 1 - print_section(step, "Re-login (TOTP should NOT be required)") - - session = requests.Session() # Fresh session - login2_response = session.post(f"{BASE_URL}/auth/login", json=CREDENTIALS) - - if login2_response.status_code != 200: - print("āŒ Re-login failed") - return False - - login2_data = login2_response.json() - if login2_data.get("data", {}).get("requires_totp"): - print("āŒ Login still requires TOTP (should not after disabling)") - return False - - auth_token = login2_data["data"]["token"] - print("āœ… Logged in successfully (no TOTP required)") - - # ==================== PHASE 4: ENROLL IN TOTP ==================== - - step += 1 - print_section(step, "Enroll in TOTP") - - enroll_response = session.post( - f"{BASE_URL}/auth/totp/enroll", - headers={"Authorization": f"Bearer {auth_token}"} - ) - - if enroll_response.status_code != 201: - print(f"āŒ Enrollment failed: {enroll_response.status_code}") - print(json.dumps(enroll_response.json(), indent=2)) - return False - - enroll_data = enroll_response.json()["data"] - new_secret = enroll_data["secret"] - new_backup_codes = enroll_data["backup_codes"] - provisioning_uri = enroll_data["provisioning_uri"] - qr_code = enroll_data.get("qr_code", "") - - print(f"āœ… Enrollment initiated") - print(f" Secret: {new_secret}") - print(f" Provisioning URI: {provisioning_uri}") - print(f" QR Code: {'Present (%d bytes)' % len(qr_code) if qr_code else 'Missing'}") - print(f" Backup Codes: {len(new_backup_codes)}") - - # Save for later use - test_data['secret'] = new_secret - test_data['backup_codes'] = new_backup_codes.copy() - - # ==================== PHASE 5: VERIFY ENROLLMENT ==================== - - step += 1 - print_section(step, "Verify TOTP Enrollment") - - totp = pyotp.TOTP(new_secret) - utc_now = datetime.now(timezone.utc) - code = totp.at(utc_now) - - print(f"Generated TOTP code: {code}") - print(f"At UTC time: {utc_now.isoformat()}") - print(f"Timestamp: {utc_now.timestamp()}") - - verify_enrollment_response = session.post( - f"{BASE_URL}/auth/totp/verify-enrollment", - headers={"Authorization": f"Bearer {auth_token}"}, - json={"code": code} - ) - - if verify_enrollment_response.status_code != 200: - print(f"āŒ Verification failed: {verify_enrollment_response.status_code}") - print(json.dumps(verify_enrollment_response.json(), indent=2)) - return False - - print("āœ… TOTP enrollment verified successfully!") - - # ==================== PHASE 6: CONFIRM ENROLLMENT ==================== - - step += 1 - print_section(step, "Confirm TOTP is Enabled") - - final_status_response = session.get( - f"{BASE_URL}/auth/totp/status", - headers={"Authorization": f"Bearer {auth_token}"} - ) - - final_status = final_status_response.json()["data"] - if not final_status["totp_enabled"]: - print("āŒ TOTP not enabled after verification!") - return False - - print(f"āœ… TOTP is enabled") - print(f" Verified at: {final_status['verified_at']}") - print(f" Backup codes remaining: {final_status['backup_codes_remaining']}") - - # ==================== PHASE 7: TEST LOGIN WITH TOTP ==================== - - step += 1 - print_section(step, "Logout") - - session.post(f"{BASE_URL}/auth/logout", headers={"Authorization": f"Bearer {auth_token}"}) - print("āœ… Logged out") - - step += 1 - print_section(step, "Login (should REQUIRE TOTP)") - - session2 = requests.Session() - login3_response = session2.post(f"{BASE_URL}/auth/login", json=CREDENTIALS) - - if login3_response.status_code != 200: - print("āŒ Login failed") - return False - - login3_data = login3_response.json() - if not login3_data.get("data", {}).get("requires_totp"): - print("āŒ Login did NOT require TOTP (it should!)") - return False - - print("āœ… Login correctly requires TOTP") - - # ==================== PHASE 8: VERIFY TOTP DURING LOGIN ==================== - - step += 1 - print_section(step, "Verify TOTP Code During Login") - - utc_now = datetime.now(timezone.utc) - login_code = totp.at(utc_now) - - print(f"Generated TOTP code: {login_code}") - print(f"At UTC time: {utc_now.isoformat()}") - - verify_login_response = session2.post( - f"{BASE_URL}/auth/totp/verify", - json={"code": login_code} - ) - - if verify_login_response.status_code != 200: - print(f"āŒ TOTP login verification failed: {verify_login_response.status_code}") - print(json.dumps(verify_login_response.json(), indent=2)) - return False - - final_token = verify_login_response.json()["data"]["token"] - print("āœ… Successfully logged in with TOTP!") - print(f" Token: {final_token[:30]}...") - - # ==================== PHASE 9: TEST /auth/me ==================== - - step += 1 - print_section(step, "Confirm Logged In (/auth/me)") - - me_response = session2.get( - f"{BASE_URL}/auth/me", - headers={"Authorization": f"Bearer {final_token}"} - ) - - if me_response.status_code != 200: - print("āŒ /auth/me failed") - return False - - me_data = me_response.json()["data"] - print(f"āœ… Confirmed logged in as: {me_data['user']['email']}") - print(f" User ID: {me_data['user']['id']}") - - # ==================== PHASE 10: TEST BACKUP CODE ==================== - - step += 1 - print_section(step, "Test Backup Code Login") - - # Logout - session2.post(f"{BASE_URL}/auth/logout", headers={"Authorization": f"Bearer {final_token}"}) - - # Fresh login - session3 = requests.Session() - login4_response = session3.post(f"{BASE_URL}/auth/login", json=CREDENTIALS) - - if not login4_response.json().get("data", {}).get("requires_totp"): - print("āŒ Login should require TOTP") - return False - - print(f"ā„¹ļø Using backup code: {test_data['backup_codes'][0]}") - - backup_verify_response = session3.post( - f"{BASE_URL}/auth/totp/verify", - json={"code": test_data['backup_codes'][0], "is_backup_code": True} - ) - - if backup_verify_response.status_code != 200: - print("āŒ Backup code login failed") - print(json.dumps(backup_verify_response.json(), indent=2)) - return False - - backup_token = backup_verify_response.json()["data"]["token"] - print(f"āœ… Logged in with backup code!") - - # Remove used code - used_code = test_data['backup_codes'].pop(0) - - # ==================== PHASE 11: CHECK BACKUP CODES REMAINING ==================== - - step += 1 - print_section(step, "Check Backup Codes Remaining") - - status3_response = session3.get( - f"{BASE_URL}/auth/totp/status", - headers={"Authorization": f"Bearer {backup_token}"} - ) - - status3_data = status3_response.json()["data"] - if status3_data['backup_codes_remaining'] != 9: - print(f"āŒ Expected 9 backup codes, got {status3_data['backup_codes_remaining']}") - return False - - print(f"āœ… Backup codes remaining: {status3_data['backup_codes_remaining']} (was 10, now 9)") - - # ==================== PHASE 12: REGENERATE BACKUP CODES ==================== - - step += 1 - print_section(step, "Regenerate Backup Codes") - - regen_response = session3.post( - f"{BASE_URL}/auth/totp/regenerate-backup-codes", - headers={"Authorization": f"Bearer {backup_token}"}, - json={"password": CREDENTIALS["password"]} - ) - - if regen_response.status_code != 200: - print("āŒ Failed to regenerate backup codes") - print(json.dumps(regen_response.json(), indent=2)) - return False - - regenerated_codes = regen_response.json()["data"]["backup_codes"] - print(f"āœ… Regenerated {len(regenerated_codes)} backup codes") - - # Update saved codes - test_data['backup_codes'] = regenerated_codes.copy() - - # ==================== SUCCESS ==================== - - save_test_data() - - print("\n" + "="*70) - print("šŸŽ‰ ALL TESTS PASSED!") - print("="*70) - - print("\nāœ… TEST SUMMARY:") - print(f" 1. āœ… Initial login (with/without TOTP)") - print(f" 2. āœ… Check TOTP status") - print(f" 3. āœ… Disable TOTP") - print(f" 4. āœ… Logout") - print(f" 5. āœ… Re-login without TOTP") - print(f" 6. āœ… Enroll in TOTP") - print(f" 7. āœ… Verify enrollment") - print(f" 8. āœ… Confirm TOTP enabled") - print(f" 9. āœ… Logout") - print(f" 10. āœ… Login with TOTP required") - print(f" 11. āœ… Verify TOTP during login") - print(f" 12. āœ… Confirm logged in (/auth/me)") - print(f" 13. āœ… Login with backup code") - print(f" 14. āœ… Check backup codes decremented") - print(f" 15. āœ… Regenerate backup codes") - - print(f"\nšŸ“± Current TOTP Secret:") - print(f" {test_data['secret']}") - - print(f"\nšŸ”‘ Current Backup Codes ({len(test_data['backup_codes'])}):") - for i, code in enumerate(test_data['backup_codes'], 1): - print(f" {i:2d}. {code}") - - print("\n" + "="*70) - - return True - - except requests.exceptions.ConnectionError: - print(f"\nāŒ CONNECTION ERROR - Server not running at {BASE_URL}") - return False - except KeyError as e: - print(f"\nāŒ UNEXPECTED RESPONSE STRUCTURE: Missing key {e}") - import traceback - traceback.print_exc() - return False - except Exception as e: - print(f"\nāŒ UNEXPECTED ERROR: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 46816dd..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 59f21fd..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Pytest configuration and fixtures.""" -import pytest -from unittest.mock import Mock, patch -from datetime import datetime, timedelta, timezone - -from gatehouse_app import create_app -from gatehouse_app.extensions import db as _db -from gatehouse_app.models import User, Organization, OrganizationMember, AuthenticationMethod -from gatehouse_app.services.auth_service import AuthService -from gatehouse_app.utils.constants import OrganizationRole, AuthMethodType -from gatehouse_app.services.external_auth_service import ExternalProviderConfig, OAuthState - - -@pytest.fixture(scope="session") -def app(): - """Create application for testing.""" - app = create_app("testing") - return app - - -@pytest.fixture(scope="function") -def db(app): - """Create database for testing.""" - with app.app_context(): - _db.create_all() - yield _db - _db.session.remove() - _db.drop_all() - - -@pytest.fixture(scope="function") -def client(app, db): - """Create test client.""" - return app.test_client() - - -@pytest.fixture(scope="function") -def test_user(db): - """Create a test user.""" - email = "test@example.com" - password = "TestPassword123!" - full_name = "Test User" - - user = AuthService.register_user( - email=email, - password=password, - full_name=full_name, - ) - - # Store password for testing - user._test_password = password - - return user - - -@pytest.fixture(scope="function") -def test_organization(db, test_user): - """Create a test organization.""" - from gatehouse_app.services.organization_service import OrganizationService - - org = OrganizationService.create_organization( - name="Test Organization", - slug="test-org", - owner_user_id=test_user.id, - description="A test organization", - ) - - return org - - -@pytest.fixture(scope="function") -def authenticated_client(client, test_user): - """Create authenticated test client.""" - # Login - response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - - assert response.status_code == 200 - - return client - - -@pytest.fixture(scope="function") -def second_test_user(db): - """Create a second test user.""" - email = "second@example.com" - password = "TestPassword123!" - full_name = "Second User" - - user = AuthService.register_user( - email=email, - password=password, - full_name=full_name, - ) - - user._test_password = password - - return user - - -# ============================================================================= -# External Auth Testing Fixtures -# ============================================================================= - -@pytest.fixture(scope="function") -def google_provider_config(db, test_organization): - """Create a Google OAuth provider configuration.""" - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-google-client-id", - client_secret_encrypted="encrypted-google-secret", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=[ - "http://localhost:3000/callback", - "http://localhost:5173/callback", - "https://myapp.example.com/callback", - ], - is_active=True, - ) - config.save() - return config - - -@pytest.fixture(scope="function") -def github_provider_config(db, test_organization): - """Create a GitHub OAuth provider configuration.""" - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GITHUB.value, - client_id="test-github-client-id", - client_secret_encrypted="encrypted-github-secret", - auth_url="https://github.com/login/oauth/authorize", - token_url="https://github.com/login/oauth/access_token", - userinfo_url="https://api.github.com/user", - scopes=["read:user", "user:email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - return config - - -@pytest.fixture(scope="function") -def microsoft_provider_config(db, test_organization): - """Create a Microsoft OAuth provider configuration.""" - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.MICROSOFT.value, - client_id="test-microsoft-client-id", - client_secret_encrypted="encrypted-microsoft-secret", - auth_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", - userinfo_url="https://graph.microsoft.com/oidc/userinfo", - scopes=["openid", "profile", "email", "User.Read"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - return config - - -@pytest.fixture(scope="function") -def user_with_google_link(db, test_user): - """Create a test user with a linked Google account.""" - auth_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123456789", - provider_data={ - "email": test_user.email, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - }, - verified=True, - is_primary=False, - ) - auth_method.save() - return test_user - - -@pytest.fixture(scope="function") -def user_with_multiple_providers(db, test_user): - """Create a test user with multiple linked external accounts.""" - # Google account - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={ - "email": test_user.email, - "name": "Test User", - }, - verified=True, - ) - google_method.save() - - # GitHub account - github_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GITHUB, - provider_user_id="github-456", - provider_data={ - "email": "user@github.com", - "name": "Test User", - }, - verified=True, - ) - github_method.save() - - return test_user - - -@pytest.fixture -def mock_google_oauth_token_response(): - """Mock Google OAuth token response.""" - return { - "access_token": "ya29.mock-access-token", - "refresh_token": "1//mock-refresh-token", - "id_token": "eyJ.mock-id-token", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "openid profile email", - } - - -@pytest.fixture -def mock_google_oauth_user_info(): - """Mock Google OAuth user info response.""" - return { - "sub": "google-123456789", - "name": "Test User", - "given_name": "Test", - "family_name": "User", - "picture": "https://example.com/avatar.jpg", - "email": "testuser@gmail.com", - "email_verified": True, - } - - -@pytest.fixture -def mock_github_oauth_token_response(): - """Mock GitHub OAuth token response.""" - return { - "access_token": "gho_mock-access-token", - "token_type": "bearer", - "scope": "read:user,user:email", - } - - -@pytest.fixture -def mock_github_oauth_user_info(): - """Mock GitHub OAuth user info response.""" - return { - "id": 123456789, - "login": "testuser", - "name": "Test User", - "email": "testuser@github.com", - "avatar_url": "https://example.com/avatar.jpg", - "type": "User", - } - - -@pytest.fixture -def oauth_login_state(db, test_organization): - """Create an OAuth state for login flow.""" - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - nonce="mock-nonce", - code_verifier="mock-code-verifier", - code_challenge="mock-code-challenge", - lifetime_seconds=600, - ) - return state - - -@pytest.fixture -def oauth_register_state(db, test_organization): - """Create an OAuth state for register flow.""" - state = OAuthState.create_state( - flow_type="register", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - lifetime_seconds=600, - ) - return state - - -@pytest.fixture -def oauth_link_state(db, test_user, test_organization): - """Create an OAuth state for link flow.""" - state = OAuthState.create_state( - flow_type="link", - provider_type=AuthMethodType.GOOGLE, - user_id=test_user.id, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - lifetime_seconds=600, - ) - return state - - -@pytest.fixture -def expired_oauth_state(db, test_organization): - """Create an expired OAuth state.""" - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - lifetime_seconds=-1, # Already expired - ) - return state - - -@pytest.fixture -def used_oauth_state(db, test_organization): - """Create a used OAuth state.""" - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - lifetime_seconds=600, - ) - state.mark_used() - return state - - -@pytest.fixture -def mock_oauth_flow_mocks(): - """Common mocks for OAuth flow tests.""" - with patch.object( - ExternalProviderConfig, 'get_client_secret', return_value='mock-secret' - ) as mock_get_secret, patch( - 'requests.post' - ) as mock_post, patch( - 'requests.get' - ) as mock_get: - # Mock token exchange response - mock_post.return_value.json.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - mock_post.return_value.raise_for_status = Mock() - - # Mock user info response - mock_get.return_value.json.return_value = { - "sub": "google-123", - "email": "testuser@gmail.com", - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - } - mock_get.return_value.raise_for_status = Mock() - - yield { - 'get_secret': mock_get_secret, - 'post': mock_post, - 'get': mock_get, - } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py deleted file mode 100644 index c66cd71..0000000 --- a/tests/integration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Integration tests package.""" diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py deleted file mode 100644 index cb801a5..0000000 --- a/tests/integration/test_auth_flow.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Integration tests for authentication flow.""" -import pytest -import json - - -@pytest.mark.integration -class TestAuthFlow: - """Integration tests for authentication endpoints.""" - - def test_register_login_logout_flow(self, client, db): - """Test complete registration, login, and logout flow.""" - # Register - register_data = { - "email": "integration@example.com", - "password": "TestPassword123!", - "password_confirm": "TestPassword123!", - "full_name": "Integration Test", - } - - response = client.post( - "/api/v1/auth/register", - data=json.dumps(register_data), - content_type="application/json", - ) - - assert response.status_code == 201 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert data["data"]["user"]["email"] == "integration@example.com" - - # Logout - response = client.post("/api/v1/auth/logout") - assert response.status_code == 200 - - # Login - login_data = { - "email": "integration@example.com", - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - - # Logout again - response = client.post("/api/v1/auth/logout") - assert response.status_code == 200 - - def test_get_current_user_authenticated(self, authenticated_client): - """Test getting current user when authenticated.""" - response = authenticated_client.get("/api/v1/auth/me") - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - - def test_get_current_user_unauthenticated(self, client): - """Test getting current user when not authenticated.""" - response = client.get("/api/v1/auth/me") - - assert response.status_code == 401 - data = response.get_json() - assert data["success"] is False - - def test_invalid_credentials(self, client, test_user): - """Test login with invalid credentials.""" - login_data = { - "email": test_user.email, - "password": "WrongPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 401 - data = response.get_json() - assert data["success"] is False - - def test_duplicate_registration(self, client, test_user): - """Test registering with existing email.""" - register_data = { - "email": test_user.email, - "password": "TestPassword123!", - "password_confirm": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/register", - data=json.dumps(register_data), - content_type="application/json", - ) - - assert response.status_code == 409 - data = response.get_json() - assert data["success"] is False diff --git a/tests/integration/test_external_auth_flow.py b/tests/integration/test_external_auth_flow.py deleted file mode 100644 index 3436833..0000000 --- a/tests/integration/test_external_auth_flow.py +++ /dev/null @@ -1,696 +0,0 @@ -"""Integration tests for external authentication API flows.""" -import pytest -import json -from unittest.mock import patch, Mock - -from gatehouse_app.services.external_auth_service import ( - ExternalAuthService, - ExternalProviderConfig, - OAuthState, -) -from gatehouse_app.services.audit_service import AuditService -from gatehouse_app.utils.constants import AuthMethodType, OrganizationRole -from gatehouse_app.models import User, AuthenticationMethod, OrganizationMember - - -@pytest.mark.integration -class TestExternalAuthApiFlows: - """Integration tests for external auth API flows.""" - - def test_complete_account_linking_flow( - self, app, db, client, test_user, test_organization - ): - """Test complete account linking flow: initiate → callback → complete.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - client_secret_encrypted="encrypted-secret", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - assert login_response.status_code == 200 - token = login_response.get_json()["data"]["token"] - - with patch.object( - ExternalAuthService, '_exchange_code' - ) as mock_exchange, patch.object( - ExternalAuthService, '_get_user_info' - ) as mock_get_user_info: - # Mock external provider responses - mock_exchange.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-123", - "email": "user@gmail.com", - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - # Step 1: Initiate link flow - initiate_response = client.post( - "/api/v1/auth/external/google/link", - json={}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert initiate_response.status_code == 200 - initiate_data = initiate_response.get_json() - assert "authorization_url" in initiate_data["data"] - assert "state" in initiate_data["data"] - state = initiate_data["data"]["state"] - - # Step 2: Simulate callback (complete link flow) - with patch.object(AuditService, 'log_external_auth_link_completed'): - complete_response = client.get( - f"/api/v1/auth/external/google/callback", - query_string={ - "code": "mock-auth-code", - "state": state, - }, - ) - # The callback returns 200 on success - assert complete_response.status_code == 200 - - # Verify account is linked - auth_method = AuthenticationMethod.query.filter_by( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - ).first() - assert auth_method is not None - - def test_complete_login_flow( - self, app, db, client, test_user, test_organization - ): - """Test complete login flow: initiate → callback → authenticate.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - client_secret_encrypted="encrypted-secret", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create authentication method for user - auth_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - auth_method.save() - - with patch.object( - ExternalAuthService, '_exchange_code' - ) as mock_exchange, patch.object( - ExternalAuthService, '_get_user_info' - ) as mock_get_user_info: - # Mock external provider responses - mock_exchange.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-123", - "email": test_user.email, - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - # Initiate login flow - login_init_response = client.get( - "/api/v1/auth/external/google/authorize", - query_string={"flow": "login"}, - ) - assert login_init_response.status_code == 200 - login_init_data = login_init_response.get_json() - assert "authorization_url" in login_init_data["data"] - state = login_init_data["data"]["state"] - - # Simulate callback - callback_response = client.get( - f"/api/v1/auth/external/google/callback", - query_string={ - "code": "mock-auth-code", - "state": state, - }, - ) - assert callback_response.status_code == 200 - callback_data = callback_response.get_json() - - assert callback_data["success"] is True - assert callback_data["flow_type"] == "login" - assert "token" in callback_data["data"] - assert callback_data["data"]["user"]["id"] == test_user.id - - def test_account_unlinking_flow( - self, app, db, client, test_user, test_organization - ): - """Test account unlinking flow.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Create password auth method - password_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.PASSWORD, - provider_user_id=test_user.id, - ) - password_method.save() - - # Create Google auth method - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - google_method.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Unlink Google account - with patch.object(AuditService, 'log_external_auth_unlink'): - unlink_response = client.delete( - "/api/v1/auth/external/google/unlink", - headers={"Authorization": f"Bearer {token}"}, - ) - assert unlink_response.status_code == 200 - unlink_data = unlink_response.get_json() - assert "success" in unlink_data or "message" in unlink_data - - # Verify account is unlinked - auth_method = AuthenticationMethod.query.filter_by( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - ).first() - assert auth_method is None - - def test_provider_configuration_crud( - self, app, db, client, test_user, test_organization - ): - """Test provider configuration CRUD operations.""" - with app.app_context(): - # Create organization membership as admin - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.ADMIN, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Step 1: Create provider config - with patch.object(AuditService, 'log_external_auth_config_create'): - create_response = client.post( - "/api/v1/auth/external/google/config", - json={ - "client_id": "new-client-id", - "client_secret": "new-client-secret", - "scopes": ["openid", "profile", "email"], - "redirect_uris": ["http://localhost:3000/callback"], - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert create_response.status_code == 201 - create_data = create_response.get_json() - assert create_data["data"]["provider_type"] == "google" - assert create_data["data"]["client_id"] == "new-client-id" - - config_id = create_data["data"]["id"] - - # Step 2: List providers - list_response = client.get( - "/api/v1/auth/external/providers", - headers={"Authorization": f"Bearer {token}"}, - ) - assert list_response.status_code == 200 - list_data = list_response.get_json() - google_provider = next( - p for p in list_data["data"]["providers"] if p["id"] == "google" - ) - assert google_provider["is_configured"] is True - - # Step 3: Get provider config - get_response = client.get( - "/api/v1/auth/external/google/config", - headers={"Authorization": f"Bearer {token}"}, - ) - assert get_response.status_code == 200 - get_data = get_response.get_json() - assert get_data["data"]["client_id"] == "new-client-id" - - # Step 4: Update provider config - with patch.object(AuditService, 'log_external_auth_config_update'): - update_response = client.post( - "/api/v1/auth/external/google/config", - json={ - "client_id": "updated-client-id", - "client_secret": "updated-client-secret", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert update_response.status_code == 200 - update_data = update_response.get_json() - assert update_data["data"]["client_id"] == "updated-client-id" - - # Step 5: Delete provider config - with patch.object(AuditService, 'log_external_auth_config_delete'): - delete_response = client.delete( - "/api/v1/auth/external/google/config", - headers={"Authorization": f"Bearer {token}"}, - ) - assert delete_response.status_code == 200 - - # Verify deletion - get_deleted_response = client.get( - "/api/v1/auth/external/google/config", - headers={"Authorization": f"Bearer {token}"}, - ) - assert get_deleted_response.status_code == 404 - - def test_invalid_state_error(self, app, db, client, test_user, test_organization): - """Test error handling for invalid OAuth state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Try callback with invalid state - callback_response = client.get( - "/api/v1/auth/external/google/callback", - query_string={ - "code": "mock-auth-code", - "state": "invalid-state", - }, - ) - assert callback_response.status_code == 400 - callback_data = callback_response.get_json() - assert callback_data["error_type"] == "INVALID_STATE" - - def test_expired_state_error(self, app, db, client, test_user, test_organization): - """Test error handling for expired OAuth state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create expired state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - lifetime_seconds=-1, # Already expired - ) - - # Try callback with expired state - callback_response = client.get( - "/api/v1/auth/external/google/callback", - query_string={ - "code": "mock-auth-code", - "state": state.state, - }, - ) - assert callback_response.status_code == 400 - callback_data = callback_response.get_json() - assert callback_data["error_type"] == "INVALID_STATE" - - def test_provider_not_configured_error( - self, app, db, client, test_user, test_organization - ): - """Test error handling when provider is not configured.""" - with app.app_context(): - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Try to link with unconfigured provider - link_response = client.post( - "/api/v1/auth/external/google/link", - json={}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert link_response.status_code == 400 - link_data = link_response.get_json() - assert link_data["error_type"] == "PROVIDER_NOT_CONFIGURED" - - def test_linked_accounts_list(self, app, db, client, test_user, test_organization): - """Test listing linked accounts.""" - with app.app_context(): - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Create authentication methods - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={ - "email": test_user.email, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - }, - verified=True, - ) - google_method.save() - - github_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GITHUB, - provider_user_id="github-456", - provider_data={ - "email": "user@github.com", - "name": "Test User", - }, - verified=True, - ) - github_method.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # List linked accounts - list_response = client.get( - "/api/v1/auth/external/linked-accounts", - headers={"Authorization": f"Bearer {token}"}, - ) - assert list_response.status_code == 200 - list_data = list_response.get_json() - - assert len(list_data["data"]["linked_accounts"]) == 2 - assert list_data["data"]["unlink_available"] is True - - def test_non_admin_cannot_manage_providers( - self, app, db, client, test_user, test_organization - ): - """Test that non-admin users cannot manage provider configurations.""" - with app.app_context(): - # Create organization membership as regular member - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Try to create provider config (should fail) - create_response = client.post( - "/api/v1/auth/external/google/config", - json={ - "client_id": "client-id", - "client_secret": "client-secret", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert create_response.status_code == 403 - assert create_response.get_json()["error_type"] == "FORBIDDEN" - - def test_unsupported_provider_error( - self, app, db, client, test_user, test_organization - ): - """Test error handling for unsupported provider.""" - with app.app_context(): - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Try to link with unsupported provider - link_response = client.post( - "/api/v1/auth/external/unsupported/link", - json={}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert link_response.status_code == 400 - link_data = link_response.get_json() - assert link_data["error_type"] == "UNSUPPORTED_PROVIDER" - - -@pytest.mark.integration -class TestExternalAuthAuditLogging: - """Integration tests for audit logging in external auth flows.""" - - @patch('gatehouse_app.services.audit_service.AuditService') - def test_audit_log_on_link_initiated( - self, mock_audit, app, db, client, test_user, test_organization - ): - """Test audit log is created when link flow is initiated.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Initiate link flow - link_response = client.post( - "/api/v1/auth/external/google/link", - json={}, - headers={"Authorization": f"Bearer {token}"}, - ) - - # Verify audit log was called - mock_audit.log_external_auth_link_initiated.assert_called_once() - - @patch('gatehouse_app.services.audit_service.AuditService') - def test_audit_log_on_unlink( - self, mock_audit, app, db, client, test_user, test_organization - ): - """Test audit log is created when account is unlinked.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create organization membership - member = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - member.save() - - # Create password auth method - password_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.PASSWORD, - provider_user_id=test_user.id, - ) - password_method.save() - - # Create Google auth method - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - google_method.save() - - # Login to get token - login_response = client.post( - "/api/v1/auth/login", - json={ - "email": test_user.email, - "password": test_user._test_password, - }, - ) - token = login_response.get_json()["data"]["token"] - - # Unlink Google account - unlink_response = client.delete( - "/api/v1/auth/external/google/unlink", - headers={"Authorization": f"Bearer {token}"}, - ) - - # Verify audit log was called - mock_audit.log_external_auth_unlink.assert_called_once() \ No newline at end of file diff --git a/tests/integration/test_mfa_compliance.py b/tests/integration/test_mfa_compliance.py deleted file mode 100644 index 5e6bbfe..0000000 --- a/tests/integration/test_mfa_compliance.py +++ /dev/null @@ -1,933 +0,0 @@ -"""Integration tests for MFA compliance enforcement.""" -import pytest -import json -from datetime import datetime, timezone, timedelta -from gatehouse_app.models.user import User -from gatehouse_app.models.organization import Organization -from gatehouse_app.models.organization_member import OrganizationMember -from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy -from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance -from gatehouse_app.models.user_security_policy import UserSecurityPolicy -from gatehouse_app.models.session import Session -from gatehouse_app.utils.constants import MfaPolicyMode, MfaComplianceStatus, UserStatus, MfaRequirementOverride -from gatehouse_app.services.mfa_policy_service import MfaPolicyService - - -@pytest.mark.integration -class TestMfaComplianceLogin: - """Integration tests for MFA compliance during login.""" - - def test_login_with_no_policy(self, client, db, test_user): - """Test login with no MFA policy (should work normally).""" - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert "token" in data["data"] - # No MFA compliance info should be present when no policy exists - assert "mfa_compliance" not in data["data"] - assert "requires_mfa_enrollment" not in data["data"] - - def test_login_with_optional_policy(self, client, db, test_user, test_organization): - """Test login with optional MFA policy (should work normally).""" - # Create an optional MFA policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert "token" in data["data"] - # MFA compliance should be present but status should be not_applicable - assert "mfa_compliance" in data["data"] - assert data["data"]["mfa_compliance"]["overall_status"] == "not_applicable" - assert "requires_mfa_enrollment" not in data["data"] - - def test_login_with_required_policy_in_grace_period(self, client, db, test_user, test_organization): - """Test login with required policy within grace period (should work with warning).""" - # Create a required MFA policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert "token" in data["data"] - # MFA compliance should be present with in_grace status - assert "mfa_compliance" in data["data"] - assert data["data"]["mfa_compliance"]["overall_status"] == "in_grace" - assert "requires_mfa_enrollment" not in data["data"] - assert "totp" in data["data"]["mfa_compliance"]["missing_methods"] - - def test_login_with_required_policy_after_deadline(self, client, db, test_user, test_organization): - """Test login with required policy after deadline (should get compliance-only session).""" - # Create a required MFA policy with past deadline - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - db.session.commit() - - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert "token" in data["data"] - # Should have compliance-only session - assert data["data"]["requires_mfa_enrollment"] is True - assert "mfa_compliance" in data["data"] - assert data["data"]["mfa_compliance"]["overall_status"] in ["past_due", "suspended"] - - def test_login_with_suspended_user(self, client, db, test_user, test_organization): - """Test login with compliance suspended user (should get compliance-only session).""" - # Set user status to compliance suspended - test_user.status = UserStatus.COMPLIANCE_SUSPENDED - db.session.commit() - - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "user" in data["data"] - assert "token" in data["data"] - # Should have compliance-only session - assert data["data"]["requires_mfa_enrollment"] is True - - -@pytest.mark.integration -class TestMfaComplianceAccess: - """Integration tests for MFA compliance access control.""" - - def test_compliance_only_session_denied_full_access(self, client, db, test_user, test_organization): - """Test that compliance-only session cannot access full access endpoints.""" - # Create a required MFA policy with past deadline - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - - # Create a compliance-only session - session = Session( - user_id=test_user.id, - token="compliance_only_token", - expires_at=datetime.now(timezone.utc) + timedelta(hours=1), - is_compliance_only=True, - ) - db.session.add(session) - db.session.commit() - - # Try to access a full-access endpoint (get_my_organizations) - response = client.get( - "/api/v1/users/me/organizations", - headers={"Authorization": "Bearer compliance_only_token"}, - ) - - assert response.status_code == 403 - data = response.get_json() - assert data["success"] is False - assert data["error_type"] == "MFA_COMPLIANCE_REQUIRED" - - def test_compliance_only_session_can_access_mfa_enrollment(self, client, db, test_user, test_organization): - """Test that compliance-only session can access MFA enrollment endpoints.""" - # Create a required MFA policy with past deadline - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - - # Create a compliance-only session - session = Session( - user_id=test_user.id, - token="compliance_only_token", - expires_at=datetime.now(timezone.utc) + timedelta(hours=1), - is_compliance_only=True, - ) - db.session.add(session) - db.session.commit() - - # Try to access MFA enrollment endpoint (should work) - response = client.get( - "/api/v1/auth/totp/status", - headers={"Authorization": "Bearer compliance_only_token"}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - def test_compliance_only_session_can_access_logout(self, client, db, test_user, test_organization): - """Test that compliance-only session can access logout endpoint.""" - # Create a required MFA policy with past deadline - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - - # Create a compliance-only session - session = Session( - user_id=test_user.id, - token="compliance_only_token", - expires_at=datetime.now(timezone.utc) + timedelta(hours=1), - is_compliance_only=True, - ) - db.session.add(session) - db.session.commit() - - # Try to access logout endpoint (should work) - response = client.post( - "/api/v1/auth/logout", - headers={"Authorization": "Bearer compliance_only_token"}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - -@pytest.mark.integration -class TestMfaComplianceWebAuthn: - """Integration tests for MFA compliance with WebAuthn login.""" - - def test_webauthn_login_with_required_policy_in_grace_period(self, client, db, test_user, test_organization): - """Test WebAuthn login with required policy within grace period.""" - # Create a required MFA policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Note: Full WebAuthn login test would require WebAuthn setup - # This test verifies the compliance response structure - login_data = { - "email": test_user.email, - "password": "TestPassword123!", - } - - response = client.post( - "/api/v1/auth/login", - data=json.dumps(login_data), - content_type="application/json", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - assert "mfa_compliance" in data["data"] - assert data["data"]["mfa_compliance"]["overall_status"] == "in_grace" - - -@pytest.mark.integration -class TestMfaComplianceOIDC: - """Integration tests for MFA compliance with OIDC authorization.""" - - def test_oidc_authorize_with_compliance_required(self, client, db, test_user, test_organization, app): - """Test OIDC authorize with compliance required (should show error).""" - # Create a required MFA policy with past deadline - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - db.session.commit() - - # Try OIDC authorize with credentials - response = client.post( - "/oidc/authorize", - data={ - "client_id": "test_client", - "redirect_uri": "http://localhost:8080/callback", - "response_type": "code", - "scope": "openid profile email", - "state": "test_state", - "email": test_user.email, - "password": "TestPassword123!", - }, - ) - - # Should return login page with error - assert response.status_code == 200 - assert b"Your account requires multi factor enrollment before using single sign on" in response.data - - -# ============================================================================= -# Phase 4: Edge Case Tests -# ============================================================================= - - -@pytest.mark.integration -class TestMfaComplianceMultiOrg: - """Integration tests for multi-organization MFA compliance edge cases.""" - - def test_user_with_multiple_orgs_different_policies(self, client, db, test_user): - """Test user belonging to multiple orgs with different MFA policies.""" - # Create two organizations - org1 = Organization( - name="Org1", - slug="org1-test-multi", - ) - org2 = Organization( - name="Org2", - slug="org2-test-multi", - ) - db.session.add_all([org1, org2]) - db.session.commit() - - # Add user to both orgs - membership1 = OrganizationMember( - user_id=test_user.id, - organization_id=org1.id, - role="member", - ) - membership2 = OrganizationMember( - user_id=test_user.id, - organization_id=org2.id, - role="member", - ) - db.session.add_all([membership1, membership2]) - db.session.commit() - - # Create different policies for each org - # Org1: OPTIONAL (no requirement) - policy1 = OrganizationSecurityPolicy( - organization_id=org1.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - # Org2: REQUIRE_TOTP (strictest) - policy2 = OrganizationSecurityPolicy( - organization_id=org2.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add_all([policy1, policy2]) - db.session.commit() - - # Evaluate user MFA state - compliance_summary = MfaPolicyService.evaluate_user_mfa_state(test_user) - - # Overall status should reflect the strictest policy (REQUIRE_TOTP from org2) - assert compliance_summary.overall_status == MfaComplianceStatus.IN_GRACE.value - assert "totp" in compliance_summary.missing_methods - - # Verify per-org breakdown - assert len(compliance_summary.orgs) == 2 - org1_status = next((o for o in compliance_summary.orgs if o.organization_id == org1.id), None) - org2_status = next((o for o in compliance_summary.orgs if o.organization_id == org2.id), None) - - assert org1_status is not None - assert org2_status is not None - assert org1_status.status == MfaComplianceStatus.NOT_APPLICABLE.value - assert org2_status.status == MfaComplianceStatus.IN_GRACE.value - - def test_user_with_multiple_orgs_all_suspended(self, client, db, test_user): - """Test user with multiple orgs where all require MFA and are past due.""" - # Create two organizations - org1 = Organization( - name="Org1", - slug="org1-test-suspended", - ) - org2 = Organization( - name="Org2", - slug="org2-test-suspended", - ) - db.session.add_all([org1, org2]) - db.session.commit() - - # Add user to both orgs - membership1 = OrganizationMember( - user_id=test_user.id, - organization_id=org1.id, - role="member", - ) - membership2 = OrganizationMember( - user_id=test_user.id, - organization_id=org2.id, - role="member", - ) - db.session.add_all([membership1, membership2]) - db.session.commit() - - # Create required policies - policy1 = OrganizationSecurityPolicy( - organization_id=org1.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - policy2 = OrganizationSecurityPolicy( - organization_id=org2.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add_all([policy1, policy2]) - db.session.commit() - - # Create past-due compliance records for both - past_deadline = datetime.now(timezone.utc) - timedelta(days=1) - compliance1 = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=org1.id, - status=MfaComplianceStatus.SUSPENDED, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=30), - deadline_at=past_deadline, - suspended_at=past_deadline, - ) - compliance2 = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=org2.id, - status=MfaComplianceStatus.SUSPENDED, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=30), - deadline_at=past_deadline, - suspended_at=past_deadline, - ) - db.session.add_all([compliance1, compliance2]) - db.session.commit() - - # Evaluate user MFA state - compliance_summary = MfaPolicyService.evaluate_user_mfa_state(test_user) - - # Overall status should be SUSPENDED - assert compliance_summary.overall_status == MfaComplianceStatus.SUSPENDED.value - - def test_strictest_mode_selection(self): - """Test that get_strictest_mode returns the most restrictive policy.""" - modes = [ - MfaPolicyMode.DISABLED.value, - MfaPolicyMode.OPTIONAL.value, - MfaPolicyMode.REQUIRE_TOTP.value, - ] - result = MfaPolicyService.get_strictest_mode(modes) - assert result == MfaPolicyMode.REQUIRE_TOTP.value - - # Test with REQUIRE_TOTP_OR_WEBAUTHN (strictest) - modes_strictest = [ - MfaPolicyMode.REQUIRE_TOTP.value, - MfaPolicyMode.REQUIRE_WEBAUTHN.value, - MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value, - ] - result = MfaPolicyService.get_strictest_mode(modes_strictest) - assert result == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value - - -@pytest.mark.integration -class TestMfaComplianceUserOverrides: - """Integration tests for user override edge cases.""" - - def test_user_override_inherit_mode(self, client, db, test_user, test_organization): - """Test INHERIT mode - org policy applies as is.""" - # Create a required policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create INHERIT override (default behavior) - override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.INHERIT, - ) - db.session.add(override) - db.session.commit() - - # Get effective policy - effective = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - # Should inherit org policy - assert effective.effective_mode == MfaPolicyMode.REQUIRE_TOTP.value - assert effective.requires_totp is True - assert effective.is_exempt is False - - def test_user_override_required_mode(self, client, db, test_user, test_organization): - """Test REQUIRED mode - user always required to have MFA.""" - # Create an optional policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create REQUIRED override - override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.REQUIRED, - ) - db.session.add(override) - db.session.commit() - - # Get effective policy - effective = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - # Should be upgraded to REQUIRE_TOTP_OR_WEBAUTHN - assert effective.effective_mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value - assert effective.requires_totp is True - assert effective.requires_webauthn is True - assert effective.is_exempt is False - - def test_user_override_exempt_mode(self, client, db, test_user, test_organization): - """Test EXEMPT mode - org policy does not apply.""" - # Create a required policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create EXEMPT override - override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - ) - db.session.add(override) - db.session.commit() - - # Get effective policy - effective = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - # Should be exempt from policy - assert effective.is_exempt is True - assert effective.effective_mode == MfaPolicyMode.DISABLED.value - assert effective.requires_totp is False - assert effective.requires_webauthn is False - - def test_get_override_summary(self, client, db, test_user, test_organization): - """Test getting override summary for a user.""" - # No override exists - summary = MfaPolicyService.get_override_summary(test_user.id, test_organization.id) - - assert summary["has_override"] is False - assert summary["mode"] == "inherit" - - # Create an override - override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - ) - db.session.add(override) - db.session.commit() - - # Get summary again - summary = MfaPolicyService.get_override_summary(test_user.id, test_organization.id) - - assert summary["has_override"] is True - assert summary["mode"] == "exempt" - assert summary["is_exempt"] is True - - -@pytest.mark.integration -class TestMfaCompliancePolicyChanges: - """Integration tests for policy changes affecting existing users.""" - - def test_policy_change_triggers_compliance_reevaluation(self, client, db, test_user, test_organization): - """Test that policy change triggers compliance reevaluation.""" - # Create initial optional policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create compliance record (should be NOT_APPLICABLE) - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.NOT_APPLICABLE, - policy_version=1, - ) - db.session.add(compliance) - db.session.commit() - - # Update policy to REQUIRE_TOTP - MfaPolicyService.create_org_policy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - updated_by_user_id=test_user.id, - ) - - # Reevaluate all compliance - updated_count = MfaPolicyService.reevaluate_all_org_compliance(test_organization.id) - - # Should have updated at least one record - assert updated_count >= 1 - - # Check compliance status was updated - updated_compliance = MfaPolicyService.get_user_compliance(test_user.id, test_organization.id) - assert updated_compliance.status == MfaComplianceStatus.IN_GRACE.value - assert updated_compliance.deadline_at is not None - - def test_policy_relaxation_clears_requirements(self, client, db, test_user, test_organization): - """Test that relaxing policy clears compliance requirements.""" - # Create required policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create IN_GRACE compliance record - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.IN_GRACE, - policy_version=1, - applied_at=datetime.now(timezone.utc), - deadline_at=datetime.now(timezone.utc) + timedelta(days=14), - ) - db.session.add(compliance) - db.session.commit() - - # Update policy to OPTIONAL - MfaPolicyService.create_org_policy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - updated_by_user_id=test_user.id, - ) - - # Reevaluate compliance - MfaPolicyService.reevaluate_all_org_compliance(test_organization.id) - - # Check compliance status was updated to NOT_APPLICABLE - updated_compliance = MfaPolicyService.get_user_compliance(test_user.id, test_organization.id) - assert updated_compliance.status == MfaComplianceStatus.NOT_APPLICABLE.value - - -@pytest.mark.integration -class TestMfaComplianceScheduledJob: - """Integration tests for the MFA compliance scheduled job.""" - - def test_transition_to_suspended(self, client, db, test_user, test_organization): - """Test that past-due users are transitioned to suspended.""" - # Create required policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # Create past-due compliance record - past_deadline = datetime.now(timezone.utc) - timedelta(hours=1) - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=15), - deadline_at=past_deadline, - ) - db.session.add(compliance) - db.session.commit() - - # Run the job - now = datetime.now(timezone.utc) - suspended_count = MfaPolicyService.transition_to_suspended_if_past_due(now) - - # Should have suspended the user - assert suspended_count >= 1 - - # Check compliance status - updated_compliance = MfaPolicyService.get_user_compliance(test_user.id, test_organization.id) - assert updated_compliance.status == MfaComplianceStatus.SUSPENDED.value - assert updated_compliance.suspended_at is not None - - # Check user status - db.refresh(test_user) - assert test_user.status == UserStatus.COMPLIANCE_SUSPENDED - - def test_check_and_restore_user_status(self, client, db, test_user, test_organization): - """Test that suspended users are restored when they become compliant.""" - # Create required policy - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add(policy) - db.session.commit() - - # User is suspended - test_user.status = UserStatus.COMPLIANCE_SUSPENDED - db.session.commit() - - # Create EXEMPT override to clear requirement - override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - ) - db.session.add(override) - db.session.commit() - - # Check and restore status - restored = MfaPolicyService.check_and_restore_user_status(test_user.id) - - # Should have restored user - assert restored is True - db.refresh(test_user) - assert test_user.status == UserStatus.ACTIVE - - -@pytest.mark.integration -class TestMfaComplianceMultiOrgAggregate: - """Integration tests for multi-org aggregate state calculation.""" - - def test_get_multi_org_aggregate_state(self, client, db, test_user): - """Test aggregate state calculation for multi-org user.""" - # Create two organizations - org1 = Organization( - name="AggOrg1", - slug="agg-org1-test", - ) - org2 = Organization( - name="AggOrg2", - slug="agg-org2-test", - ) - db.session.add_all([org1, org2]) - db.session.commit() - - # Add user to both - membership1 = OrganizationMember( - user_id=test_user.id, - organization_id=org1.id, - role="member", - ) - membership2 = OrganizationMember( - user_id=test_user.id, - organization_id=org2.id, - role="member", - ) - db.session.add_all([membership1, membership2]) - db.session.commit() - - # Create policies - policy1 = OrganizationSecurityPolicy( - organization_id=org1.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - policy2 = OrganizationSecurityPolicy( - organization_id=org2.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - db.session.add_all([policy1, policy2]) - db.session.commit() - - # Get aggregate state - aggregate = MfaPolicyService.get_multi_org_aggregate_state(test_user) - - # Verify structure - assert "overall_status" in aggregate - assert "strictest_mode" in aggregate - assert "missing_methods" in aggregate - assert "requiring_org_count" in aggregate - assert "requiring_orgs" in aggregate - assert "per_org_details" in aggregate - - # Strictest mode should be REQUIRE_TOTP_OR_WEBAUTHN - assert aggregate["strictest_mode"] == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value - - # Both orgs should require MFA - assert aggregate["requiring_org_count"] == 2 - assert len(aggregate["requiring_orgs"]) == 2 - assert len(aggregate["per_org_details"]) == 2 \ No newline at end of file diff --git a/tests/integration/test_oidc_flow.py b/tests/integration/test_oidc_flow.py deleted file mode 100644 index 1e29194..0000000 --- a/tests/integration/test_oidc_flow.py +++ /dev/null @@ -1,1028 +0,0 @@ -"""Integration tests for OIDC flow. - -This module tests the complete OIDC authorization code flow with PKCE, -including discovery, authorization, token exchange, userinfo, refresh, -and revocation endpoints. -""" -import hashlib -import base64 -import json -import secrets -import time -import pytest - - -@pytest.mark.integration -class TestOIDCDiscovery: - """Tests for OIDC Discovery endpoint.""" - - def test_discovery_returns_valid_json(self, client): - """Test that discovery endpoint returns valid JSON configuration.""" - response = client.get("/.well-known/openid-configuration") - - assert response.status_code == 200 - data = response.get_json() - - # Check required OIDC discovery fields - assert "issuer" in data - assert "authorization_endpoint" in data - assert "token_endpoint" in data - assert "userinfo_endpoint" in data - assert "jwks_uri" in data - assert "registration_endpoint" in data - assert "revocation_endpoint" in data - assert "introspection_endpoint" in data - - def test_discovery_cache_header(self, client): - """Test that discovery endpoint sets cache header.""" - response = client.get("/.well-known/openid-configuration") - - assert response.status_code == 200 - cache_header = response.headers.get("Cache-Control", "") - assert "max-age" in cache_header - - def test_discovery_scopes_supported(self, client): - """Test that discovery returns supported scopes.""" - response = client.get("/.well-known/openid-configuration") - - data = response.get_json() - assert "scopes_supported" in data - assert "openid" in data["scopes_supported"] - assert "profile" in data["scopes_supported"] - assert "email" in data["scopes_supported"] - - def test_discovery_response_types(self, client): - """Test that discovery returns supported response types.""" - response = client.get("/.well-known/openid-configuration") - - data = response.get_json() - assert "response_types_supported" in data - assert "code" in data["response_types_supported"] - - def test_discovery_algorithms(self, client): - """Test that discovery returns supported algorithms.""" - response = client.get("/.well-known/openid-configuration") - - data = response.get_json() - assert "id_token_signing_alg_values_supported" in data - assert "RS256" in data["id_token_signing_alg_values_supported"] - - -@pytest.mark.integration -class TestOIDCJWKS: - """Tests for OIDC JWKS endpoint.""" - - def test_jwks_returns_valid_jwks(self, client): - """Test that JWKS endpoint returns valid JWKS document.""" - response = client.get("/oidc/jwks") - - assert response.status_code == 200 - data = response.get_json() - - assert "keys" in data - assert isinstance(data["keys"], list) - assert len(data["keys"]) > 0 - - # Check key structure - key = data["keys"][0] - assert "kty" in key - assert "kid" in key - assert "alg" in key - assert key["kty"] == "RSA" - - def test_jwks_cache_header(self, client): - """Test that JWKS endpoint sets cache header.""" - response = client.get("/oidc/jwks") - - assert response.status_code == 200 - cache_header = response.headers.get("Cache-Control", "") - assert "max-age" in cache_header - - def test_jwks_contains_signing_key(self, client, app): - """Test that JWKS contains a valid signing key.""" - from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService - - with app.app_context(): - # Initialize with a key - jwks_service = OIDCJWKSService() - jwks_service.initialize_with_key() - - response = client.get("/oidc/jwks") - data = response.get_json() - - assert len(data["keys"]) > 0 - key = data["keys"][0] - assert "n" in key - assert "e" in key - - -@pytest.mark.integration -class TestOIDCClientRegistration: - """Tests for OIDC Client Registration endpoint.""" - - def test_register_client_success(self, client, test_organization): - """Test successful client registration.""" - registration_data = { - "client_name": "Test OAuth2 Client", - "redirect_uris": ["https://example.com/callback"], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "scope": "openid profile email", - "token_endpoint_auth_method": "client_secret_basic", - } - - response = client.post( - "/oidc/register", - data=json.dumps(registration_data), - content_type="application/json", - ) - - assert response.status_code == 201 - data = response.get_json() - assert data["success"] is True - assert "client_id" in data["data"] - assert "client_secret" in data["data"] - assert data["data"]["client_name"] == "Test OAuth2 Client" - - def test_register_client_missing_name(self, client): - """Test client registration fails without client_name.""" - registration_data = { - "redirect_uris": ["https://example.com/callback"], - } - - response = client.post( - "/oidc/register", - data=json.dumps(registration_data), - content_type="application/json", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_register_client_missing_redirect_uris(self, client): - """Test client registration fails without redirect_uris.""" - registration_data = { - "client_name": "Test Client", - } - - response = client.post( - "/oidc/register", - data=json.dumps(registration_data), - content_type="application/json", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_register_client_invalid_redirect_uri(self, client): - """Test client registration fails with invalid redirect URI.""" - registration_data = { - "client_name": "Test Client", - "redirect_uris": ["not-a-valid-uri"], - } - - response = client.post( - "/oidc/register", - data=json.dumps(registration_data), - content_type="application/json", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - -@pytest.mark.integration -class TestOIDCAuthorizationCodeFlow: - """Tests for OIDC Authorization Code Flow with PKCE.""" - - @pytest.fixture - def test_client(self, client, test_organization, test_user): - """Create a test OIDC client.""" - from gatehouse_app.models import OIDCClient - - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test PKCE Client", - client_id="test_pkce_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=True, - require_pkce=True, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - return client_data - - def _generate_pkce_pair(self): - """Generate PKCE code verifier and challenge. - - Returns: - Tuple of (code_verifier, code_challenge) - """ - code_verifier = secrets.token_urlsafe(32) - - # Generate S256 code challenge - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") - - return code_verifier, code_challenge - - def test_authorization_endpoint_missing_params(self, client, test_client): - """Test authorization fails with missing required parameters.""" - response = client.get("/oidc/authorize") - - assert response.status_code == 400 - - def test_authorization_endpoint_invalid_client(self, client): - """Test authorization fails with invalid client_id.""" - response = client.get( - "/oidc/authorize", - query_string={ - "client_id": "nonexistent_client", - "redirect_uri": "https://example.com/callback", - "response_type": "code", - } - ) - - assert response.status_code == 302 # Redirect with error - - def test_authorization_endpoint_invalid_redirect_uri(self, client, test_client): - """Test authorization fails with invalid redirect_uri.""" - response = client.get( - "/oidc/authorize", - query_string={ - "client_id": test_client.client_id, - "redirect_uri": "https://malicious.com/callback", - "response_type": "code", - } - ) - - assert response.status_code == 302 # Redirect with error - - def test_authorization_endpoint_unsupported_response_type(self, client, test_client): - """Test authorization fails with unsupported response_type.""" - response = client.get( - "/oidc/authorize", - query_string={ - "client_id": test_client.client_id, - "redirect_uri": "https://example.com/callback", - "response_type": "token", # Not supported - } - ) - - assert response.status_code == 302 # Redirect with error - - def test_authorization_endpoint_invalid_scope(self, client, test_client): - """Test authorization fails with invalid scope.""" - response = client.get( - "/oidc/authorize", - query_string={ - "client_id": test_client.client_id, - "redirect_uri": "https://example.com/callback", - "response_type": "code", - "scope": "invalid_scope", - } - ) - - assert response.status_code == 302 # Redirect with error - - def test_authorization_code_flow_with_pkce(self, client, app, test_client, test_user): - """Test complete authorization code flow with PKCE.""" - # Step 1: Generate PKCE parameters - code_verifier, code_challenge = self._generate_pkce_pair() - state = secrets.token_urlsafe(16) - nonce = secrets.token_urlsafe(16) - - # Step 2: Request authorization code via POST with credentials - response = client.post( - "/oidc/authorize", - data={ - "client_id": test_client.client_id, - "redirect_uri": "https://example.com/callback", - "response_type": "code", - "scope": "openid profile email", - "state": state, - "nonce": nonce, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "email": test_user.email, - "password": test_user._test_password, - } - ) - - assert response.status_code == 302 - - # Parse redirect URL to get authorization code - redirect_location = response.headers.get("Location", "") - assert "code=" in redirect_location - - # Extract code from redirect - from urllib.parse import parse_qs, urlparse - parsed = urlparse(redirect_location) - params = parse_qs(parsed.query) - auth_code = params.get("code", [None])[0] - returned_state = params.get("state", [None])[0] - - assert auth_code is not None - assert returned_state == state - - def test_authorization_code_exchange_success(self, client, app, test_client, test_user): - """Test successful token exchange with authorization code.""" - from gatehouse_app.services.oidc_service import OIDCService - from gatehouse_app.models import OIDCAuthCode - from gatehouse_app.extensions import db - - # First, generate an authorization code - with app.app_context(): - code = OIDCService.generate_authorization_code( - client_id=test_client.client_id, - user_id=test_user.id, - redirect_uri="https://example.com/callback", - scope=["openid", "profile", "email"], - state="test_state", - nonce="test_nonce", - code_challenge=None, - code_challenge_method=None, - ) - - # Get the code hash for lookup - code_hash = hashlib.sha256(code.encode()).hexdigest() - - # Step 2: Exchange code for tokens - response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "https://example.com/callback", - "client_id": test_client.client_id, - "client_secret": "", # Not needed for this test - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - # Check token response - tokens = data["data"] - assert "access_token" in tokens - assert "token_type" in tokens - assert tokens["token_type"] == "Bearer" - assert "id_token" in tokens - assert "refresh_token" in tokens - assert "expires_in" in tokens - - def test_token_exchange_invalid_code(self, client, test_client): - """Test token exchange fails with invalid authorization code.""" - response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "code": "invalid_code", - "redirect_uri": "https://example.com/callback", - "client_id": test_client.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_token_exchange_missing_code(self, client, test_client): - """Test token exchange fails without authorization code.""" - response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "redirect_uri": "https://example.com/callback", - "client_id": test_client.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_token_exchange_pkce_verification(self, client, app, test_client, test_user): - """Test PKCE verification during token exchange.""" - from gatehouse_app.services.oidc_service import OIDCService - - # Generate PKCE pair - code_verifier, code_challenge = self._generate_pkce_pair() - - # Generate authorization code with PKCE - with app.app_context(): - code = OIDCService.generate_authorization_code( - client_id=test_client.client_id, - user_id=test_user.id, - redirect_uri="https://example.com/callback", - scope=["openid", "profile", "email"], - state="test_state", - nonce="test_nonce", - code_challenge=code_challenge, - code_challenge_method="S256", - ) - - # Token exchange without code_verifier should fail - response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "https://example.com/callback", - "client_id": test_client.client_id, - # Missing code_verifier - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user): - """Test successful token exchange with valid PKCE code verifier.""" - from gatehouse_app.services.oidc_service import OIDCService - - # Generate PKCE pair - code_verifier, code_challenge = self._generate_pkce_pair() - - # Generate authorization code with PKCE - with app.app_context(): - code = OIDCService.generate_authorization_code( - client_id=test_client.client_id, - user_id=test_user.id, - redirect_uri="https://example.com/callback", - scope=["openid", "profile", "email"], - state="test_state", - nonce="test_nonce", - code_challenge=code_challenge, - code_challenge_method="S256", - ) - - # Token exchange with correct code_verifier - response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "https://example.com/callback", - "client_id": test_client.client_id, - "code_verifier": code_verifier, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - -@pytest.mark.integration -class TestOIDCUserInfo: - """Tests for OIDC UserInfo endpoint.""" - - @pytest.fixture - def test_client_with_user(self, client, test_organization, test_user): - """Create a test OIDC client and get tokens.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test UserInfo Client", - client_id="test_userinfo_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=False, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - # Generate tokens directly - with client.application.app_context(): - tokens = OIDCService.generate_tokens( - client_id=client_data.client_id, - user_id=test_user.id, - scope=["openid", "profile", "email"], - nonce="test_nonce", - ) - - return client_data, tokens["access_token"], test_user - - def test_userinfo_without_token(self, client): - """Test UserInfo endpoint returns 401 without token.""" - response = client.get("/oidc/userinfo") - - assert response.status_code == 401 - data = response.get_json() - assert data["success"] is False - - def test_userinfo_with_invalid_token(self, client): - """Test UserInfo endpoint returns 401 with invalid token.""" - response = client.get( - "/oidc/userinfo", - headers={"Authorization": "Bearer invalid_token"} - ) - - assert response.status_code == 401 - - def test_userinfo_with_valid_token(self, client, test_client_with_user): - """Test UserInfo endpoint returns claims with valid token.""" - _, access_token, test_user = test_client_with_user - - response = client.get( - "/oidc/userinfo", - headers={"Authorization": f"Bearer {access_token}"} - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - userinfo = data["data"] - assert "sub" in userinfo - assert userinfo["sub"] == test_user.id - assert "email" in userinfo - assert userinfo["email"] == test_user.email - - def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user): - """Test UserInfo returns correct claims based on scopes.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - - # Create client with only openid scope - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test OpenID Client", - client_id="test_openid_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code"], - response_types=["code"], - scopes=["openid"], # Only openid - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=False, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - with app.app_context(): - tokens = OIDCService.generate_tokens( - client_id=client_data.client_id, - user_id=test_user.id, - scope=["openid"], - ) - - response = client.get( - "/oidc/userinfo", - headers={"Authorization": f"Bearer {tokens['access_token']}"} - ) - - assert response.status_code == 200 - data = response.get_json() - userinfo = data["data"] - - # Should only have sub claim with openid scope - assert userinfo["sub"] == test_user.id - assert "email" not in userinfo - assert "name" not in userinfo - - -@pytest.mark.integration -class TestOIDCTokenRefresh: - """Tests for OIDC Token Refresh.""" - - @pytest.fixture - def test_client_with_refresh_token(self, client, test_organization, test_user): - """Create a test OIDC client with refresh token.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test Refresh Client", - client_id="test_refresh_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=False, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - with client.application.app_context(): - tokens = OIDCService.generate_tokens( - client_id=client_data.client_id, - user_id=test_user.id, - scope=["openid", "profile", "email"], - ) - - return client_data, tokens["refresh_token"] - - def test_refresh_access_token(self, client, test_client_with_refresh_token): - """Test refreshing an access token.""" - client_data, refresh_token = test_client_with_refresh_token - - response = client.post( - "/oidc/token", - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - tokens = data["data"] - assert "access_token" in tokens - assert "refresh_token" in tokens # Token rotation - assert "id_token" in tokens - assert "expires_in" in tokens - - def test_refresh_without_refresh_token(self, client, test_client_with_refresh_token): - """Test refresh fails without refresh token.""" - client_data = test_client_with_refresh_token[0] - - response = client.post( - "/oidc/token", - data={ - "grant_type": "refresh_token", - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_refresh_with_invalid_token(self, client, test_client_with_refresh_token): - """Test refresh fails with invalid refresh token.""" - client_data = test_client_with_refresh_token[0] - - response = client.post( - "/oidc/token", - data={ - "grant_type": "refresh_token", - "refresh_token": "invalid_refresh_token", - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - -@pytest.mark.integration -class TestOIDCTokenRevocation: - """Tests for OIDC Token Revocation.""" - - @pytest.fixture - def test_client_with_tokens(self, client, test_organization, test_user): - """Create a test OIDC client with valid tokens.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test Revoke Client", - client_id="test_revoke_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=False, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - with client.application.app_context(): - tokens = OIDCService.generate_tokens( - client_id=client_data.client_id, - user_id=test_user.id, - scope=["openid", "profile", "email"], - ) - - return client_data, tokens["access_token"], tokens["refresh_token"] - - def test_revoke_access_token(self, client, test_client_with_tokens): - """Test revoking an access token.""" - client_data, access_token, refresh_token = test_client_with_tokens - - response = client.post( - "/oidc/revoke", - data={ - "token": access_token, - "token_type_hint": "access_token", - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - def test_revoke_refresh_token(self, client, test_client_with_tokens): - """Test revoking a refresh token.""" - client_data, access_token, refresh_token = test_client_with_tokens - - response = client.post( - "/oidc/revoke", - data={ - "token": refresh_token, - "token_type_hint": "refresh_token", - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - def test_revoke_without_token(self, client, test_client_with_tokens): - """Test revocation fails without token.""" - client_data = test_client_with_tokens[0] - - response = client.post( - "/oidc/revoke", - data={ - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - def test_revoke_without_client_auth(self, client, test_client_with_tokens): - """Test revocation fails without client authentication.""" - _, access_token, _ = test_client_with_tokens - - response = client.post( - "/oidc/revoke", - data={ - "token": access_token, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 401 - - -@pytest.mark.integration -class TestOIDCTokenIntrospection: - """Tests for OIDC Token Introspection.""" - - @pytest.fixture - def test_client_with_tokens(self, client, test_organization, test_user): - """Create a test OIDC client with valid tokens.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - - client_data = OIDCClient( - organization_id=test_organization.id, - name="Test Introspect Client", - client_id="test_introspect_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=False, - ) - from gatehouse_app.extensions import db - db.session.add(client_data) - db.session.commit() - - with client.application.app_context(): - tokens = OIDCService.generate_tokens( - client_id=client_data.client_id, - user_id=test_user.id, - scope=["openid", "profile", "email"], - ) - - return client_data, tokens["access_token"] - - def test_introspect_active_token(self, client, test_client_with_tokens): - """Test introspecting an active token.""" - client_data, access_token = test_client_with_tokens - - response = client.post( - "/oidc/introspect", - data={ - "token": access_token, - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["success"] is True - - result = data["data"] - assert result["active"] is True - assert "sub" in result - assert "exp" in result - - def test_introspect_without_token(self, client, test_client_with_tokens): - """Test introspection fails without token.""" - client_data = test_client_with_tokens[0] - - response = client.post( - "/oidc/introspect", - data={ - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 400 - data = response.get_json() - assert data["success"] is False - - -@pytest.mark.integration -class TestOIDCCompleteFlow: - """Tests for complete OIDC authentication flow.""" - - def test_complete_oidc_flow(self, client, app, test_organization, test_user): - """Test complete OIDC authorization code flow with PKCE.""" - from gatehouse_app.models import OIDCClient - from gatehouse_app.services.oidc_service import OIDCService - from gatehouse_app.extensions import db - - # Create a test client - with app.app_context(): - client_data = OIDCClient( - organization_id=test_organization.id, - name="Complete Flow Client", - client_id="complete_flow_client", - client_secret_hash="dummy_hash", - redirect_uris=["https://example.com/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scopes=["openid", "profile", "email"], - token_endpoint_auth_method="client_secret_basic", - is_active=True, - is_confidential=False, - require_pkce=True, - ) - db.session.add(client_data) - db.session.commit() - - # Generate PKCE parameters - code_verifier = secrets.token_urlsafe(32) - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") - state = secrets.token_urlsafe(16) - nonce = secrets.token_urlsafe(16) - - # Step 1: Authorization Request - auth_response = client.post( - "/oidc/authorize", - data={ - "client_id": client_data.client_id, - "redirect_uri": "https://example.com/callback", - "response_type": "code", - "scope": "openid profile email", - "state": state, - "nonce": nonce, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "email": test_user.email, - "password": test_user._test_password, - } - ) - - assert auth_response.status_code == 302 - - # Extract authorization code - redirect_location = auth_response.headers.get("Location", "") - from urllib.parse import parse_qs, urlparse - parsed = urlparse(redirect_location) - params = parse_qs(parsed.query) - auth_code = params.get("code", [None])[0] - - assert auth_code is not None - - # Step 2: Token Exchange - token_response = client.post( - "/oidc/token", - data={ - "grant_type": "authorization_code", - "code": auth_code, - "redirect_uri": "https://example.com/callback", - "client_id": client_data.client_id, - "code_verifier": code_verifier, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert token_response.status_code == 200 - token_data = token_response.get_json() - tokens = token_data["data"] - - access_token = tokens["access_token"] - refresh_token = tokens["refresh_token"] - id_token = tokens["id_token"] - - # Step 3: UserInfo Request - userinfo_response = client.get( - "/oidc/userinfo", - headers={"Authorization": f"Bearer {access_token}"} - ) - - assert userinfo_response.status_code == 200 - userinfo_data = userinfo_response.get_json() - assert userinfo_data["data"]["sub"] == test_user.id - - # Step 4: Token Refresh - refresh_response = client.post( - "/oidc/token", - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert refresh_response.status_code == 200 - refresh_data = refresh_response.get_json() - new_access_token = refresh_data["data"]["access_token"] - new_refresh_token = refresh_data["data"]["refresh_token"] - - # Step 5: Token Revocation - revoke_response = client.post( - "/oidc/revoke", - data={ - "token": new_refresh_token, - "token_type_hint": "refresh_token", - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert revoke_response.status_code == 200 - - # Verify refresh token was revoked - refresh_after_revoke = client.post( - "/oidc/token", - data={ - "grant_type": "refresh_token", - "refresh_token": new_refresh_token, - "client_id": client_data.client_id, - }, - content_type="application/x-www-form-urlencoded", - ) - - assert refresh_after_revoke.status_code == 400 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index ea3f8b9..0000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests package.""" diff --git a/tests/unit/test_mfa_policy_models.py b/tests/unit/test_mfa_policy_models.py deleted file mode 100644 index 07dcdd1..0000000 --- a/tests/unit/test_mfa_policy_models.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Unit tests for MFA policy models.""" -import pytest -from datetime import datetime, timezone, timedelta -from gatehouse_app.models import ( - User, - Organization, - OrganizationMember, - OrganizationSecurityPolicy, - UserSecurityPolicy, - MfaPolicyCompliance, - Session, -) -from gatehouse_app.utils.constants import ( - UserStatus, - MfaPolicyMode, - MfaComplianceStatus, - MfaRequirementOverride, - SessionStatus, - OrganizationRole, -) - - -@pytest.mark.unit -class TestOrganizationSecurityPolicyModel: - """Tests for OrganizationSecurityPolicy model.""" - - def test_create_org_security_policy(self, db, test_organization): - """Test creating an organization security policy.""" - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - notify_days_before=7, - ) - policy.save() - - assert policy.id is not None - assert policy.organization_id == test_organization.id - assert policy.mfa_policy_mode == MfaPolicyMode.OPTIONAL - assert policy.mfa_grace_period_days == 14 - assert policy.notify_days_before == 7 - assert policy.policy_version == 1 - assert policy.created_at is not None - - def test_org_security_policy_to_dict(self, db, test_organization): - """Test organization security policy to_dict method.""" - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=7, - notify_days_before=3, - ) - policy.save() - - policy_dict = policy.to_dict() - - assert "id" in policy_dict - assert "organization_id" in policy_dict - assert policy_dict["organization_id"] == test_organization.id - assert "mfa_policy_mode" in policy_dict - assert "mfa_grace_period_days" in policy_dict - - def test_org_security_policy_relationships(self, db, test_organization): - """Test organization security policy relationships.""" - policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - ) - policy.save() - - # Test relationship - assert policy.organization is not None - assert policy.organization.id == test_organization.id - - -@pytest.mark.unit -class TestUserSecurityPolicyModel: - """Tests for UserSecurityPolicy model.""" - - def test_create_user_security_policy(self, db, test_user, test_organization): - """Test creating a user security policy.""" - policy = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.INHERIT, - ) - policy.save() - - assert policy.id is not None - assert policy.user_id == test_user.id - assert policy.organization_id == test_organization.id - assert policy.mfa_override_mode == MfaRequirementOverride.INHERIT - assert policy.force_totp is False - assert policy.force_webauthn is False - - def test_user_security_policy_with_overrides(self, db, test_user, test_organization): - """Test user security policy with override settings.""" - policy = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.REQUIRED, - force_totp=True, - force_webauthn=False, - ) - policy.save() - - assert policy.mfa_override_mode == MfaRequirementOverride.REQUIRED - assert policy.force_totp is True - assert policy.force_webauthn is False - - def test_user_security_policy_exempt(self, db, test_user, test_organization): - """Test user security policy with exempt override.""" - policy = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - ) - policy.save() - - assert policy.mfa_override_mode == MfaRequirementOverride.EXEMPT - - def test_user_security_policy_relationships(self, db, test_user, test_organization): - """Test user security policy relationships.""" - policy = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.INHERIT, - ) - policy.save() - - # Test relationships - assert policy.user is not None - assert policy.user.id == test_user.id - assert policy.organization is not None - assert policy.organization.id == test_organization.id - - -@pytest.mark.unit -class TestMfaPolicyComplianceModel: - """Tests for MfaPolicyCompliance model.""" - - def test_create_mfa_policy_compliance(self, db, test_user, test_organization): - """Test creating an MFA policy compliance record.""" - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.NOT_APPLICABLE, - policy_version=1, - ) - compliance.save() - - assert compliance.id is not None - assert compliance.user_id == test_user.id - assert compliance.organization_id == test_organization.id - assert compliance.status == MfaComplianceStatus.NOT_APPLICABLE - assert compliance.policy_version == 1 - assert compliance.notification_count == 0 - - def test_mfa_policy_compliance_in_grace(self, db, test_user, test_organization): - """Test MFA compliance record in grace period.""" - now = datetime.now(timezone.utc) - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.IN_GRACE, - policy_version=1, - applied_at=now, - deadline_at=now + timedelta(days=14), - ) - compliance.save() - - assert compliance.status == MfaComplianceStatus.IN_GRACE - assert compliance.applied_at is not None - assert compliance.deadline_at is not None - assert compliance.deadline_at > now - - def test_mfa_policy_compliance_compliant(self, db, test_user, test_organization): - """Test MFA compliance record when compliant.""" - now = datetime.now(timezone.utc) - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.COMPLIANT, - policy_version=1, - applied_at=now - timedelta(days=30), - deadline_at=now - timedelta(days=16), - compliant_at=now - timedelta(days=16), - ) - compliance.save() - - assert compliance.status == MfaComplianceStatus.COMPLIANT - assert compliance.compliant_at is not None - - def test_mfa_policy_compliance_suspended(self, db, test_user, test_organization): - """Test MFA compliance record when suspended.""" - now = datetime.now(timezone.utc) - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.SUSPENDED, - policy_version=1, - applied_at=now - timedelta(days=30), - deadline_at=now - timedelta(days=16), - suspended_at=now - timedelta(days=16), - ) - compliance.save() - - assert compliance.status == MfaComplianceStatus.SUSPENDED - assert compliance.suspended_at is not None - - def test_mfa_policy_compliance_relationships(self, db, test_user, test_organization): - """Test MFA compliance relationships.""" - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.NOT_APPLICABLE, - policy_version=1, - ) - compliance.save() - - # Test relationships - assert compliance.user is not None - assert compliance.user.id == test_user.id - assert compliance.organization is not None - assert compliance.organization.id == test_organization.id - - -@pytest.mark.unit -class TestSessionModelComplianceFlag: - """Tests for Session model compliance flag.""" - - def test_session_default_not_compliance_only(self, db, test_user): - """Test that sessions are not compliance only by default.""" - session = Session( - user_id=test_user.id, - token="test-token-123", - status=SessionStatus.ACTIVE, - expires_at=datetime.now(timezone.utc) + timedelta(hours=8), - last_activity_at=datetime.now(timezone.utc), - ) - session.save() - - assert session.is_compliance_only is False - - def test_session_compliance_only(self, db, test_user): - """Test creating a compliance-only session.""" - session = Session( - user_id=test_user.id, - token="compliance-token-123", - status=SessionStatus.ACTIVE, - expires_at=datetime.now(timezone.utc) + timedelta(hours=8), - last_activity_at=datetime.now(timezone.utc), - is_compliance_only=True, - ) - session.save() - - assert session.is_compliance_only is True - - def test_session_to_dict_excludes_token(self, db, test_user): - """Test that session to_dict excludes the token.""" - session = Session( - user_id=test_user.id, - token="test-token-456", - status=SessionStatus.ACTIVE, - expires_at=datetime.now(timezone.utc) + timedelta(hours=8), - last_activity_at=datetime.now(timezone.utc), - ) - session.save() - - session_dict = session.to_dict() - - assert "id" in session_dict - assert "user_id" in session_dict - assert "is_compliance_only" in session_dict - assert session_dict["is_compliance_only"] is False - - -@pytest.mark.unit -class TestUserStatusComplianceSuspended: - """Tests for UserStatus.COMPLIANCE_SUSPENDED.""" - - def test_compliance_suspended_status_exists(self): - """Test that COMPLIANCE_SUSPENDED status exists.""" - assert UserStatus.COMPLIANCE_SUSPENDED.value == "compliance_suspended" - - def test_create_compliance_suspended_user(self, db): - """Test creating a compliance suspended user.""" - user = User( - email="suspended@example.com", - full_name="Suspended User", - status=UserStatus.COMPLIANCE_SUSPENDED, - ) - user.save() - - assert user.status == UserStatus.COMPLIANCE_SUSPENDED diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py deleted file mode 100644 index 1fce0b5..0000000 --- a/tests/unit/test_models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Unit tests for models.""" -import pytest -from datetime import datetime -from gatehouse_app.models import User, Organization -from gatehouse_app.utils.constants import UserStatus - - -@pytest.mark.unit -class TestUserModel: - """Tests for User model.""" - - def test_create_user(self, db): - """Test creating a user.""" - user = User( - email="test@example.com", - full_name="Test User", - status=UserStatus.ACTIVE, - ) - user.save() - - assert user.id is not None - assert user.email == "test@example.com" - assert user.full_name == "Test User" - assert user.status == UserStatus.ACTIVE - assert user.created_at is not None - assert user.deleted_at is None - - def test_user_to_dict(self, test_user): - """Test user to_dict method.""" - user_dict = test_user.to_dict() - - assert "id" in user_dict - assert "email" in user_dict - assert user_dict["email"] == test_user.email - assert "created_at" in user_dict - - def test_user_soft_delete(self, test_user): - """Test soft deleting a user.""" - test_user.delete(soft=True) - - assert test_user.deleted_at is not None - assert isinstance(test_user.deleted_at, datetime) - - -@pytest.mark.unit -class TestOrganizationModel: - """Tests for Organization model.""" - - def test_create_organization(self, db): - """Test creating an organization.""" - org = Organization( - name="Test Org", - slug="test-org", - description="Test organization", - ) - org.save() - - assert org.id is not None - assert org.name == "Test Org" - assert org.slug == "test-org" - assert org.is_active is True - assert org.created_at is not None - - def test_organization_to_dict(self, test_organization): - """Test organization to_dict method.""" - org_dict = test_organization.to_dict() - - assert "id" in org_dict - assert "name" in org_dict - assert org_dict["name"] == test_organization.name - assert "slug" in org_dict - - def test_get_member_count(self, test_organization): - """Test getting member count.""" - count = test_organization.get_member_count() - assert count == 1 # Only the owner diff --git a/tests/unit/test_services/__init__.py b/tests/unit/test_services/__init__.py deleted file mode 100644 index 4c0ee2c..0000000 --- a/tests/unit/test_services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Services unit tests package.""" diff --git a/tests/unit/test_services/test_auth_service.py b/tests/unit/test_services/test_auth_service.py deleted file mode 100644 index fecab35..0000000 --- a/tests/unit/test_services/test_auth_service.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Unit tests for AuthService.""" -import pytest -from gatehouse_app.services.auth_service import AuthService -from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError -from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError -from gatehouse_app.utils.constants import UserStatus, AuthMethodType - - -@pytest.mark.unit -class TestAuthService: - """Tests for AuthService.""" - - def test_register_user(self, db): - """Test user registration.""" - email = "newuser@example.com" - password = "SecurePassword123!" - full_name = "New User" - - user = AuthService.register_user( - email=email, - password=password, - full_name=full_name, - ) - - assert user.id is not None - assert user.email == email.lower() - assert user.full_name == full_name - assert user.status == UserStatus.ACTIVE - assert user.has_password_auth() - - def test_register_duplicate_email(self, db, test_user): - """Test registering with duplicate email.""" - with pytest.raises(EmailAlreadyExistsError): - AuthService.register_user( - email=test_user.email, - password="SomePassword123!", - ) - - def test_authenticate_success(self, db, test_user): - """Test successful authentication.""" - user = AuthService.authenticate( - email=test_user.email, - password=test_user._test_password, - ) - - assert user.id == test_user.id - assert user.last_login_at is not None - - def test_authenticate_wrong_password(self, db, test_user): - """Test authentication with wrong password.""" - with pytest.raises(InvalidCredentialsError): - AuthService.authenticate( - email=test_user.email, - password="WrongPassword123!", - ) - - def test_authenticate_nonexistent_user(self, db): - """Test authentication with non-existent email.""" - with pytest.raises(InvalidCredentialsError): - AuthService.authenticate( - email="nonexistent@example.com", - password="SomePassword123!", - ) - - def test_create_session(self, app, db, test_user): - """Test creating a session.""" - with app.test_request_context(): - session = AuthService.create_session(test_user) - - assert session.id is not None - assert session.user_id == test_user.id - assert session.token is not None - assert session.is_active() - - def test_change_password(self, app, db, test_user): - """Test changing password.""" - with app.test_request_context(): - new_password = "NewPassword456!" - - AuthService.change_password( - user=test_user, - current_password=test_user._test_password, - new_password=new_password, - ) - - # Verify can login with new password - user = AuthService.authenticate( - email=test_user.email, - password=new_password, - ) - - assert user.id == test_user.id - - def test_change_password_wrong_current(self, app, db, test_user): - """Test changing password with wrong current password.""" - with app.test_request_context(): - with pytest.raises(InvalidCredentialsError): - AuthService.change_password( - user=test_user, - current_password="WrongPassword123!", - new_password="NewPassword456!", - ) diff --git a/tests/unit/test_services/test_external_auth_service.py b/tests/unit/test_services/test_external_auth_service.py deleted file mode 100644 index 6747484..0000000 --- a/tests/unit/test_services/test_external_auth_service.py +++ /dev/null @@ -1,698 +0,0 @@ -"""Unit tests for ExternalAuthService.""" -import pytest -from unittest.mock import Mock, patch, MagicMock -from datetime import datetime, timedelta, timezone - -from gatehouse_app.services.external_auth_service import ( - ExternalAuthService, - ExternalAuthError, - OAuthState, - ExternalProviderConfig, -) -from gatehouse_app.utils.constants import AuthMethodType -from gatehouse_app.models import User, AuthenticationMethod - - -@pytest.mark.unit -class TestExternalAuthService: - """Tests for ExternalAuthService.""" - - def test_get_provider_config_success(self, app, db, test_organization): - """Test getting provider configuration successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - client_secret_encrypted="encrypted-secret", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Get config - result = ExternalAuthService.get_provider_config( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert result.id == config.id - assert result.client_id == "test-client-id" - assert result.is_active is True - - def test_get_provider_config_not_configured(self, app, db, test_organization): - """Test getting provider configuration when not configured.""" - with app.app_context(): - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.get_provider_config( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert exc_info.value.error_type == "PROVIDER_NOT_CONFIGURED" - assert exc_info.value.status_code == 400 - - def test_get_provider_config_inactive(self, app, db, test_organization): - """Test getting provider configuration when inactive.""" - with app.app_context(): - # Create inactive provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=False, - ) - config.save() - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.get_provider_config( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert exc_info.value.error_type == "PROVIDER_NOT_CONFIGURED" - - @patch('gatehouse_app.services.external_auth_service.AuditService') - def test_initiate_link_flow_success(self, mock_audit, app, db, test_user, test_organization): - """Test initiating account linking flow successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Initiate link flow - auth_url, state = ExternalAuthService.initiate_link_flow( - user_id=test_user.id, - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - ) - - assert auth_url is not None - assert state is not None - assert len(state) == 43 # Base64 URL-safe token length - - # Verify state was created - state_record = OAuthState.query.filter_by(state=state).first() - assert state_record is not None - assert state_record.flow_type == "link" - assert state_record.user_id == test_user.id - assert state_record.provider_type == AuthMethodType.GOOGLE.value - - # Verify audit log - mock_audit.log_external_auth_link_initiated.assert_called_once() - - def test_initiate_link_flow_invalid_redirect_uri(self, app, db, test_user, test_organization): - """Test initiating link flow with invalid redirect URI.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.initiate_link_flow( - user_id=test_user.id, - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://malicious-site.com/callback", - ) - - assert exc_info.value.error_type == "INVALID_REDIRECT_URI" - - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code') - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info') - @patch('gatehouse_app.services.external_auth_service.AuditService') - def test_complete_link_flow_success( - self, mock_audit, mock_get_user_info, mock_exchange_code, - app, db, test_user, test_organization - ): - """Test completing account linking flow successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create OAuth state - state = OAuthState.create_state( - flow_type="link", - provider_type=AuthMethodType.GOOGLE, - user_id=test_user.id, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock external provider responses - mock_exchange_code.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-123", - "email": "user@gmail.com", - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - # Complete link flow - auth_method = ExternalAuthService.complete_link_flow( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert auth_method is not None - assert auth_method.user_id == test_user.id - assert auth_method.method_type == AuthMethodType.GOOGLE - assert auth_method.provider_user_id == "google-123" - - # Verify state is marked as used - state_record = OAuthState.query.get(state.id) - assert state_record.used is True - - # Verify audit log - mock_audit.log_external_auth_link_completed.assert_called_once() - - def test_complete_link_flow_invalid_state(self, app, db): - """Test completing link flow with invalid state.""" - with app.app_context(): - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.complete_link_flow( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state="invalid-state", - redirect_uri="http://localhost:3000/callback", - ) - - assert exc_info.value.error_type == "INVALID_STATE" - - def test_complete_link_flow_wrong_flow_type(self, app, db, test_organization): - """Test completing link flow with wrong flow type state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create login flow state instead of link - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.complete_link_flow( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert exc_info.value.error_type == "INVALID_FLOW_TYPE" - - def test_complete_link_flow_provider_mismatch(self, app, db, test_organization): - """Test completing link flow with provider mismatch.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create state with different provider - state = OAuthState.create_state( - flow_type="link", - provider_type=AuthMethodType.GITHUB, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.complete_link_flow( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert exc_info.value.error_type == "PROVIDER_MISMATCH" - - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code') - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info') - @patch('gatehouse_app.services.external_auth_service.AuditService') - def test_authenticate_with_provider_success( - self, mock_audit, mock_get_user_info, mock_exchange_code, - app, db, test_user, test_organization - ): - """Test authenticating with provider successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create authentication method for user - auth_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - auth_method.save() - - # Create OAuth state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock external provider responses - mock_exchange_code.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-123", - "email": test_user.email, - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - # Authenticate - user, session_data = ExternalAuthService.authenticate_with_provider( - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert user.id == test_user.id - assert session_data is not None - assert "token" in session_data - - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code') - @patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info') - @patch('gatehouse_app.services.external_auth_service.AuditService') - def test_authenticate_with_provider_account_not_found( - self, mock_audit, mock_get_user_info, mock_exchange_code, - app, db, test_organization - ): - """Test authenticating with provider when account not found.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create OAuth state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock external provider responses - mock_exchange_code.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-456", - "email": "newuser@gmail.com", - "email_verified": True, - "name": "New User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.authenticate_with_provider( - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert exc_info.value.error_type == "ACCOUNT_NOT_FOUND" - - @patch('gatehouse_app.services.external_auth_service.AuditService') - def test_unlink_provider_success(self, mock_audit, app, db, test_user): - """Test unlinking provider successfully.""" - with app.app_context(): - # Create password auth method first (so user has other methods) - password_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.PASSWORD, - provider_user_id=test_user.id, - ) - password_method.save() - - # Create Google auth method - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - google_method.save() - - # Unlink Google - result = ExternalAuthService.unlink_provider( - user_id=test_user.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert result is True - - # Verify auth method is deleted - method = AuthenticationMethod.query.filter_by( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - ).first() - assert method is None - - # Verify audit log - mock_audit.log_external_auth_unlink.assert_called_once() - - def test_unlink_provider_not_linked(self, app, db, test_user): - """Test unlinking provider that is not linked.""" - with app.app_context(): - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.unlink_provider( - user_id=test_user.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert exc_info.value.error_type == "PROVIDER_NOT_LINKED" - - def test_unlink_provider_last_method(self, app, db, test_user): - """Test unlinking last authentication method.""" - with app.app_context(): - # Create only Google auth method - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - google_method.save() - - with pytest.raises(ExternalAuthError) as exc_info: - ExternalAuthService.unlink_provider( - user_id=test_user.id, - provider_type=AuthMethodType.GOOGLE, - ) - - assert exc_info.value.error_type == "CANNOT_UNLINK_LAST" - - def test_get_linked_accounts(self, app, db, test_user): - """Test getting linked accounts for user.""" - with app.app_context(): - # Create Google auth method - google_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={ - "email": test_user.email, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - }, - verified=True, - ) - google_method.save() - - # Create GitHub auth method - github_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GITHUB, - provider_user_id="github-456", - provider_data={ - "email": "user@github.com", - "name": "Test User", - }, - verified=True, - ) - github_method.save() - - # Get linked accounts - accounts = ExternalAuthService.get_linked_accounts(test_user.id) - - assert len(accounts) == 2 - - google_account = next(a for a in accounts if a["provider_type"] == "google") - assert google_account["provider_user_id"] == "google-123" - assert google_account["email"] == test_user.email - - github_account = next(a for a in accounts if a["provider_type"] == "github") - assert github_account["provider_user_id"] == "github-456" - - -@pytest.mark.unit -class TestOAuthState: - """Tests for OAuthState model.""" - - def test_create_state(self, app, db): - """Test creating OAuth state.""" - with app.app_context(): - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - user_id="user-123", - organization_id="org-456", - redirect_uri="http://localhost:3000/callback", - ) - - assert state.state is not None - assert len(state.state) == 43 - assert state.flow_type == "login" - assert state.provider_type == AuthMethodType.GOOGLE.value - assert state.user_id == "user-123" - assert state.organization_id == "org-456" - assert state.redirect_uri == "http://localhost:3000/callback" - assert state.used is False - assert state.expires_at > datetime.now(timezone.utc) - - def test_is_valid(self, app, db): - """Test OAuth state validity check.""" - with app.app_context(): - # Create valid state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - ) - - assert state.is_valid() is True - - # Mark as used - state.mark_used() - - assert state.is_valid() is False - - def test_is_valid_expired(self, app, db): - """Test OAuth state validity with expiration.""" - with app.app_context(): - # Create expired state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - lifetime_seconds=-1, # Already expired - ) - - assert state.is_valid() is False - - -@pytest.mark.unit -class TestExternalProviderConfig: - """Tests for ExternalProviderConfig model.""" - - def test_is_redirect_uri_allowed(self, app, db, test_organization): - """Test redirect URI validation.""" - with app.app_context(): - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=[ - "http://localhost:3000/callback", - "https://myapp.com/callback", - ], - is_active=True, - ) - config.save() - - assert config.is_redirect_uri_allowed("http://localhost:3000/callback") is True - assert config.is_redirect_uri_allowed("https://myapp.com/callback") is True - assert config.is_redirect_uri_allowed("http://malicious.com/callback") is False - - def test_to_dict(self, app, db, test_organization): - """Test converting config to dictionary.""" - with app.app_context(): - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - result = config.to_dict() - - assert result["organization_id"] == test_organization.id - assert result["provider_type"] == AuthMethodType.GOOGLE.value - assert result["client_id"] == "test-client-id" - assert "client_secret" not in result - assert result["is_active"] is True - - def test_to_dict_include_secrets(self, app, db, test_organization): - """Test converting config to dictionary with secrets.""" - with app.app_context(): - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - client_secret_encrypted="encrypted-secret", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - result = config.to_dict(include_secrets=True) - - assert "client_secret" in result - - -@pytest.mark.unit -class TestExternalAuthError: - """Tests for ExternalAuthError exception.""" - - def test_error_creation(self): - """Test creating ExternalAuthError.""" - error = ExternalAuthError( - message="Test error message", - error_type="TEST_ERROR", - status_code=400, - ) - - assert error.message == "Test error message" - assert error.error_type == "TEST_ERROR" - assert error.status_code == 400 - - def test_error_default_status_code(self): - """Test ExternalAuthError with default status code.""" - error = ExternalAuthError( - message="Test error message", - error_type="TEST_ERROR", - ) - - assert error.status_code == 400 \ No newline at end of file diff --git a/tests/unit/test_services/test_mfa_policy_service.py b/tests/unit/test_services/test_mfa_policy_service.py deleted file mode 100644 index 6fcb749..0000000 --- a/tests/unit/test_services/test_mfa_policy_service.py +++ /dev/null @@ -1,476 +0,0 @@ -"""Unit tests for MfaPolicyService.""" -import pytest -from datetime import datetime, timezone, timedelta -from unittest.mock import patch, MagicMock - -from gatehouse_app.models import ( - User, - Organization, - OrganizationMember, - OrganizationSecurityPolicy, - UserSecurityPolicy, - MfaPolicyCompliance, - Session, -) -from gatehouse_app.services.mfa_policy_service import ( - MfaPolicyService, - OrgPolicyDto, - EffectiveUserPolicyDto, - AggregateMfaStateDto, - LoginPolicyResult, -) -from gatehouse_app.utils.constants import ( - UserStatus, - MfaPolicyMode, - MfaComplianceStatus, - MfaRequirementOverride, - SessionStatus, - OrganizationRole, -) - - -@pytest.mark.unit -class TestMfaPolicyService: - """Tests for MfaPolicyService.""" - - def test_get_org_policy_not_found(self, db, test_organization): - """Test getting organization policy when none exists.""" - policy = MfaPolicyService.get_org_policy(test_organization.id) - assert policy is None - - def test_get_org_policy_found(self, db, test_organization): - """Test getting organization policy when it exists.""" - # Create policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - ) - org_policy.save() - - policy = MfaPolicyService.get_org_policy(test_organization.id) - - assert policy is not None - assert policy.organization_id == test_organization.id - assert policy.mfa_policy_mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value - assert policy.mfa_grace_period_days == 14 - assert policy.notify_days_before == 7 - assert policy.policy_version == 1 - - def test_get_effective_user_policy_no_org_policy(self, db, test_user, test_organization): - """Test effective user policy when no org policy exists.""" - policy = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - assert policy is not None - assert policy.organization_id == test_organization.id - assert policy.effective_mode == MfaPolicyMode.DISABLED.value - assert policy.requires_totp is False - assert policy.requires_webauthn is False - assert policy.is_exempt is True - - def test_get_effective_user_policy_with_org_policy(self, db, test_user, test_organization): - """Test effective user policy with org policy and no override.""" - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - ) - org_policy.save() - - policy = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - assert policy is not None - assert policy.effective_mode == MfaPolicyMode.REQUIRE_TOTP.value - assert policy.requires_totp is True - assert policy.requires_webauthn is False - assert policy.is_exempt is False - - def test_get_effective_user_policy_with_override_inherit(self, db, test_user, test_organization): - """Test effective user policy with INHERIT override.""" - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_WEBAUTHN, - mfa_grace_period_days=7, - ) - org_policy.save() - - # Create user override - user_override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.INHERIT, - ) - user_override.save() - - policy = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - assert policy.effective_mode == MfaPolicyMode.REQUIRE_WEBAUTHN.value - assert policy.requires_webauthn is True - - def test_get_effective_user_policy_with_override_exempt(self, db, test_user, test_organization): - """Test effective user policy with EXEMPT override.""" - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - ) - org_policy.save() - - # Create user override - user_override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - ) - user_override.save() - - policy = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - assert policy.effective_mode == MfaPolicyMode.DISABLED.value - assert policy.is_exempt is True - - def test_get_effective_user_policy_with_override_required(self, db, test_user, test_organization): - """Test effective user policy with REQUIRED override.""" - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - ) - org_policy.save() - - # Create user override - user_override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.REQUIRED, - ) - user_override.save() - - policy = MfaPolicyService.get_effective_user_policy(test_user.id, test_organization.id) - - assert policy.effective_mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value - assert policy.requires_totp is True - assert policy.requires_webauthn is True - assert policy.is_exempt is False - - def test_evaluate_user_mfa_state_no_policy(self, db, test_user, test_organization): - """Test evaluating user MFA state with no policy.""" - # Create membership - membership = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - membership.save() - - state = MfaPolicyService.evaluate_user_mfa_state(test_user) - - assert state is not None - assert state.overall_status == MfaComplianceStatus.COMPLIANT.value - assert len(state.missing_methods) == 0 - assert len(state.orgs) == 1 - - def test_evaluate_user_mfa_state_with_policy(self, db, test_user, test_organization): - """Test evaluating user MFA state with policy.""" - # Create membership - membership = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - membership.save() - - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - ) - org_policy.save() - - state = MfaPolicyService.evaluate_user_mfa_state(test_user) - - assert state is not None - assert state.overall_status == MfaComplianceStatus.IN_GRACE.value - assert "totp" in state.missing_methods - assert len(state.orgs) == 1 - assert state.orgs[0].effective_mode == MfaPolicyMode.REQUIRE_TOTP.value - - def test_after_primary_auth_success_no_required_policy(self, db, test_user, test_organization): - """Test after_primary_auth_success with no required policy.""" - # Create membership - membership = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - membership.save() - - result = MfaPolicyService.after_primary_auth_success(test_user) - - assert result.can_create_full_session is True - assert result.create_compliance_only_session is False - assert result.compliance_summary.overall_status == MfaComplianceStatus.COMPLIANT.value - - def test_after_primary_auth_success_in_grace(self, db, test_user, test_organization): - """Test after_primary_auth_success when user is in grace period.""" - # Create membership - membership = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - membership.save() - - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - ) - org_policy.save() - - result = MfaPolicyService.after_primary_auth_success(test_user) - - assert result.can_create_full_session is True - assert result.create_compliance_only_session is False - assert result.compliance_summary.overall_status == MfaComplianceStatus.IN_GRACE.value - - def test_after_primary_auth_success_past_due(self, db, test_user, test_organization): - """Test after_primary_auth_success when user is past due.""" - # Create membership - membership = OrganizationMember( - user_id=test_user.id, - organization_id=test_organization.id, - role=OrganizationRole.MEMBER, - ) - membership.save() - - # Create org policy - org_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=14, - ) - org_policy.save() - - # Create compliance record past due - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.PAST_DUE, - policy_version=1, - applied_at=datetime.now(timezone.utc) - timedelta(days=30), - deadline_at=datetime.now(timezone.utc) - timedelta(days=1), - ) - compliance.save() - - result = MfaPolicyService.after_primary_auth_success(test_user) - - assert result.can_create_full_session is False - assert result.create_compliance_only_session is True - - def test_create_org_policy_new(self, db, test_organization): - """Test creating a new organization policy.""" - policy = MfaPolicyService.create_org_policy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN, - mfa_grace_period_days=14, - notify_days_before=7, - updated_by_user_id=None, - ) - - assert policy is not None - assert policy.organization_id == test_organization.id - assert policy.mfa_policy_mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN - assert policy.policy_version == 1 - - def test_create_org_policy_update(self, db, test_organization): - """Test updating an existing organization policy.""" - # Create initial policy - initial_policy = OrganizationSecurityPolicy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.OPTIONAL, - mfa_grace_period_days=14, - ) - initial_policy.save() - - # Update policy - updated_policy = MfaPolicyService.create_org_policy( - organization_id=test_organization.id, - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP, - mfa_grace_period_days=7, - updated_by_user_id=None, - ) - - assert updated_policy.mfa_policy_mode == MfaPolicyMode.REQUIRE_TOTP - assert updated_policy.mfa_grace_period_days == 7 - assert updated_policy.policy_version == 2 - - def test_set_user_override_new(self, db, test_user, test_organization): - """Test setting a new user override.""" - override = MfaPolicyService.set_user_override( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.REQUIRED, - force_totp=True, - force_webauthn=False, - updated_by_user_id=None, - ) - - assert override is not None - assert override.user_id == test_user.id - assert override.organization_id == test_organization.id - assert override.mfa_override_mode == MfaRequirementOverride.REQUIRED - assert override.force_totp is True - - def test_set_user_override_update(self, db, test_user, test_organization): - """Test updating an existing user override.""" - # Create initial override - initial_override = UserSecurityPolicy( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.INHERIT, - ) - initial_override.save() - - # Update override - updated_override = MfaPolicyService.set_user_override( - user_id=test_user.id, - organization_id=test_organization.id, - mfa_override_mode=MfaRequirementOverride.EXEMPT, - updated_by_user_id=None, - ) - - assert updated_override.mfa_override_mode == MfaRequirementOverride.EXEMPT - - def test_get_user_compliance(self, db, test_user, test_organization): - """Test getting user compliance record.""" - # Create compliance record - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.COMPLIANT, - policy_version=1, - ) - compliance.save() - - result = MfaPolicyService.get_user_compliance(test_user.id, test_organization.id) - - assert result is not None - assert result.status == MfaComplianceStatus.COMPLIANT - - def test_get_user_compliance_not_found(self, db, test_user, test_organization): - """Test getting user compliance record when none exists.""" - result = MfaPolicyService.get_user_compliance(test_user.id, test_organization.id) - assert result is None - - def test_get_org_compliance_list(self, db, test_user, test_organization): - """Test getting organization compliance list.""" - # Create compliance record - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.IN_GRACE, - policy_version=1, - deadline_at=datetime.now(timezone.utc) + timedelta(days=14), - ) - compliance.save() - - results = MfaPolicyService.get_org_compliance_list(test_organization.id) - - assert len(results) == 1 - assert results[0]["user_id"] == test_user.id - assert results[0]["status"] == MfaComplianceStatus.IN_GRACE.value - - def test_get_org_compliance_list_with_status_filter(self, db, test_user, test_organization): - """Test getting organization compliance list with status filter.""" - # Create compliance record - compliance = MfaPolicyCompliance( - user_id=test_user.id, - organization_id=test_organization.id, - status=MfaComplianceStatus.COMPLIANT, - policy_version=1, - ) - compliance.save() - - # Filter by different status - results = MfaPolicyService.get_org_compliance_list( - test_organization.id, status=MfaComplianceStatus.IN_GRACE - ) - assert len(results) == 0 - - # Filter by correct status - results = MfaPolicyService.get_org_compliance_list( - test_organization.id, status=MfaComplianceStatus.COMPLIANT - ) - assert len(results) == 1 - - -@pytest.mark.unit -class TestMfaPolicyServiceDto: - """Tests for MfaPolicyService DTOs.""" - - def test_org_policy_dto(self): - """Test OrgPolicyDto creation.""" - dto = OrgPolicyDto( - organization_id="org-123", - mfa_policy_mode=MfaPolicyMode.REQUIRE_TOTP.value, - mfa_grace_period_days=14, - notify_days_before=7, - policy_version=1, - ) - - assert dto.organization_id == "org-123" - assert dto.mfa_policy_mode == "require_totp" - assert dto.mfa_grace_period_days == 14 - - def test_effective_user_policy_dto(self): - """Test EffectiveUserPolicyDto creation.""" - dto = EffectiveUserPolicyDto( - organization_id="org-123", - effective_mode=MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value, - requires_totp=True, - requires_webauthn=True, - grace_period_days=14, - is_exempt=False, - ) - - assert dto.requires_totp is True - assert dto.requires_webauthn is True - assert dto.is_exempt is False - - def test_aggregate_mfa_state_dto(self): - """Test AggregateMfaStateDto creation.""" - dto = AggregateMfaStateDto( - overall_status=MfaComplianceStatus.IN_GRACE.value, - missing_methods=["totp"], - deadline_at="2025-02-01T00:00:00Z", - orgs=[], - ) - - assert dto.overall_status == "in_grace" - assert "totp" in dto.missing_methods - assert dto.deadline_at == "2025-02-01T00:00:00Z" - - def test_login_policy_result(self): - """Test LoginPolicyResult creation.""" - summary = AggregateMfaStateDto( - overall_status=MfaComplianceStatus.IN_GRACE.value, - missing_methods=["totp"], - orgs=[], - ) - result = LoginPolicyResult( - can_create_full_session=True, - create_compliance_only_session=False, - compliance_summary=summary, - ) - - assert result.can_create_full_session is True - assert result.create_compliance_only_session is False - assert result.compliance_summary.overall_status == "in_grace" diff --git a/tests/unit/test_services/test_oauth_flow_service.py b/tests/unit/test_services/test_oauth_flow_service.py deleted file mode 100644 index e4a13f5..0000000 --- a/tests/unit/test_services/test_oauth_flow_service.py +++ /dev/null @@ -1,533 +0,0 @@ -"""Unit tests for OAuthFlowService.""" -import pytest -from unittest.mock import Mock, patch, MagicMock -from datetime import datetime, timedelta, timezone - -from gatehouse_app.services.oauth_flow_service import ( - OAuthFlowService, - OAuthFlowError, -) -from gatehouse_app.services.external_auth_service import OAuthState, ExternalProviderConfig -from gatehouse_app.utils.constants import AuthMethodType -from gatehouse_app.models import User, AuthenticationMethod - - -@pytest.mark.unit -class TestOAuthFlowService: - """Tests for OAuthFlowService.""" - - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - def test_initiate_login_flow_success(self, mock_audit, app, db, test_organization): - """Test initiating login flow successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - with app.test_request_context(): - auth_url, state = OAuthFlowService.initiate_login_flow( - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - ) - - assert auth_url is not None - assert state is not None - assert len(state) == 43 - - # Verify state was created with correct flow type - state_record = OAuthState.query.filter_by(state=state).first() - assert state_record is not None - assert state_record.flow_type == "login" - assert state_record.organization_id == test_organization.id - - def test_initiate_login_flow_invalid_redirect_uri(self, app, db, test_organization): - """Test initiating login flow with invalid redirect URI.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - with app.test_request_context(): - with pytest.raises(OAuthFlowError) as exc_info: - OAuthFlowService.initiate_login_flow( - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://malicious.com/callback", - ) - - assert exc_info.value.error_type == "INVALID_REDIRECT_URI" - - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - def test_initiate_register_flow_success(self, mock_audit, app, db, test_organization): - """Test initiating register flow successfully.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - with app.test_request_context(): - auth_url, state = OAuthFlowService.initiate_register_flow( - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - ) - - assert auth_url is not None - assert state is not None - - # Verify state was created with correct flow type - state_record = OAuthState.query.filter_by(state=state).first() - assert state_record is not None - assert state_record.flow_type == "register" - - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService.authenticate_with_provider') - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - def test_handle_callback_login_flow( - self, mock_audit, mock_authenticate, - app, db, test_user, test_organization - ): - """Test handling callback for login flow.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create authentication method - auth_method = AuthenticationMethod( - user_id=test_user.id, - method_type=AuthMethodType.GOOGLE, - provider_user_id="google-123", - provider_data={"email": test_user.email}, - verified=True, - ) - auth_method.save() - - # Create login state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock authentication - mock_authenticate.return_value = (test_user, {"token": "session-token", "expires_in": 86400}) - - with app.test_request_context(): - result = OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert result["success"] is True - assert result["flow_type"] == "login" - assert result["user"]["id"] == test_user.id - assert result["session"]["token"] == "session-token" - - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService.complete_link_flow') - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - def test_handle_callback_link_flow( - self, mock_audit, mock_complete_link, - app, db, test_user, test_organization - ): - """Test handling callback for link flow.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create link state - state = OAuthState.create_state( - flow_type="link", - provider_type=AuthMethodType.GOOGLE, - user_id=test_user.id, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock complete link - mock_auth_method = Mock() - mock_auth_method.id = "auth-method-123" - mock_auth_method.provider_user_id = "google-123" - mock_auth_method.verified = True - mock_complete_link.return_value = mock_auth_method - - with app.test_request_context(): - result = OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert result["success"] is True - assert result["flow_type"] == "link" - assert result["linked_account"]["id"] == "auth-method-123" - - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._exchange_code') - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._get_user_info') - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._encrypt_provider_data') - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - @patch('gatehouse_app.services.auth_service.AuthService.create_session') - def test_handle_callback_register_flow( - self, mock_create_session, mock_audit, mock_encrypt, - mock_get_user_info, mock_exchange_code, - app, db, test_organization - ): - """Test handling callback for register flow.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create register state - state = OAuthState.create_state( - flow_type="register", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock external provider responses - mock_exchange_code.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - mock_get_user_info.return_value = { - "provider_user_id": "google-new-123", - "email": "newuser@gmail.com", - "email_verified": True, - "name": "New User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - mock_encrypt.return_value = { - "access_token": "mock-access-token", - "email": "newuser@gmail.com", - "name": "New User", - } - - mock_session = Mock() - mock_session.to_dict.return_value = {"token": "session-token", "expires_in": 86400} - mock_create_session.return_value = mock_session - - with app.test_request_context(): - result = OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert result["success"] is True - assert result["flow_type"] == "register" - assert result["user"]["email"] == "newuser@gmail.com" - assert result["session"]["token"] == "session-token" - - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._exchange_code') - @patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._get_user_info') - @patch('gatehouse_app.services.oauth_flow_service.AuditService') - def test_handle_callback_register_flow_email_exists( - self, mock_audit, mock_get_user_info, mock_exchange_code, - app, db, test_user, test_organization - ): - """Test handling callback for register flow when email already exists.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create register state - state = OAuthState.create_state( - flow_type="register", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - # Mock external provider responses - mock_exchange_code.return_value = { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "id_token": "mock-id-token", - "expires_in": 3600, - } - - # Return email that matches existing user - mock_get_user_info.return_value = { - "provider_user_id": "google-new-123", - "email": test_user.email, # Existing email - "email_verified": True, - "name": "Test User", - "picture": "https://example.com/avatar.jpg", - "raw_data": {}, - } - - with app.test_request_context(): - with pytest.raises(OAuthFlowError) as exc_info: - OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - redirect_uri="http://localhost:3000/callback", - ) - - assert exc_info.value.error_type == "EMAIL_EXISTS" - - def test_handle_callback_invalid_state(self, app, db): - """Test handling callback with invalid state.""" - with app.app_context(): - with app.test_request_context(): - with pytest.raises(OAuthFlowError) as exc_info: - OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state="invalid-state", - ) - - assert exc_info.value.error_type == "INVALID_STATE" - - def test_handle_callback_provider_error(self, app, db): - """Test handling callback with provider error.""" - with app.app_context(): - with app.test_request_context(): - with pytest.raises(OAuthFlowError) as exc_info: - OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code=None, - state=None, - error="access_denied", - error_description="User denied access", - ) - - assert exc_info.value.error_type == "ACCESS_DENIED" - - def test_handle_callback_unknown_flow_type(self, app, db, test_organization): - """Test handling callback with unknown flow type.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create state with unknown flow type - state = OAuthState.create_state( - flow_type="unknown", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - redirect_uri="http://localhost:3000/callback", - ) - - with app.test_request_context(): - with pytest.raises(OAuthFlowError) as exc_info: - OAuthFlowService.handle_callback( - provider_type=AuthMethodType.GOOGLE, - authorization_code="mock-auth-code", - state=state.state, - ) - - assert exc_info.value.error_type == "INVALID_FLOW_TYPE" - - def test_validate_state_valid(self, app, db, test_organization): - """Test validating a valid state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - ) - - result = OAuthFlowService.validate_state(state.state) - - assert result is not None - assert result.id == state.id - - def test_validate_state_invalid(self, app, db): - """Test validating an invalid state.""" - with app.app_context(): - result = OAuthFlowService.validate_state("nonexistent-state") - - assert result is None - - def test_validate_state_expired(self, app, db, test_organization): - """Test validating an expired state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create expired state - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - lifetime_seconds=-1, - ) - - result = OAuthFlowService.validate_state(state.state) - - assert result is None - - def test_validate_state_used(self, app, db, test_organization): - """Test validating a used state.""" - with app.app_context(): - # Create provider config - config = ExternalProviderConfig( - organization_id=test_organization.id, - provider_type=AuthMethodType.GOOGLE.value, - client_id="test-client-id", - auth_url="https://accounts.google.com/o/oauth2/v2/auth", - token_url="https://oauth2.googleapis.com/token", - userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo", - scopes=["openid", "profile", "email"], - redirect_uris=["http://localhost:3000/callback"], - is_active=True, - ) - config.save() - - # Create and mark state as used - state = OAuthState.create_state( - flow_type="login", - provider_type=AuthMethodType.GOOGLE, - organization_id=test_organization.id, - ) - state.mark_used() - - result = OAuthFlowService.validate_state(state.state) - - assert result is None - - -@pytest.mark.unit -class TestOAuthFlowError: - """Tests for OAuthFlowError exception.""" - - def test_error_creation(self): - """Test creating OAuthFlowError.""" - error = OAuthFlowError( - message="Test error message", - error_type="TEST_ERROR", - status_code=400, - ) - - assert error.message == "Test error message" - assert error.error_type == "TEST_ERROR" - assert error.status_code == 400 - - def test_error_default_status_code(self): - """Test OAuthFlowError with default status code.""" - error = OAuthFlowError( - message="Test error message", - error_type="TEST_ERROR", - ) - - assert error.status_code == 400 \ No newline at end of file diff --git a/tests/unit/test_services/test_totp_service.py b/tests/unit/test_services/test_totp_service.py deleted file mode 100644 index 9830250..0000000 --- a/tests/unit/test_services/test_totp_service.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Unit tests for TOTPService.""" -import base64 -import pytest -from gatehouse_app.services.totp_service import TOTPService - - -@pytest.mark.unit -class TestTOTPService: - """Tests for TOTPService.""" - - # Test generate_secret() - def test_generate_secret_returns_string(self): - """Test that generate_secret returns a string.""" - secret = TOTPService.generate_secret() - assert isinstance(secret, str) - - def test_generate_secret_length(self): - """Test that generate_secret returns a 32-character string.""" - secret = TOTPService.generate_secret() - assert len(secret) == 32 - - def test_generate_secret_base32_encoded(self): - """Test that generate_secret returns a base32 encoded string.""" - secret = TOTPService.generate_secret() - # Base32 characters are A-Z and 2-7 - valid_chars = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") - assert all(c in valid_chars for c in secret) - - def test_generate_secret_unique(self): - """Test that generate_secret produces unique secrets.""" - secret1 = TOTPService.generate_secret() - secret2 = TOTPService.generate_secret() - assert secret1 != secret2 - - # Test generate_provisioning_uri() - def test_generate_provisioning_uri_format(self): - """Test that provisioning URI is generated correctly.""" - email = "user@example.com" - secret = "JBSWY3DPEHPK3PXP" - issuer = "Gatehouse" - - uri = TOTPService.generate_provisioning_uri(email, secret, issuer) - - assert isinstance(uri, str) - assert uri.startswith("otpauth://totp/") - - def test_generate_provisioning_uri_contains_email(self): - """Test that provisioning URI contains the user email.""" - email = "user@example.com" - secret = "JBSWY3DPEHPK3PXP" - issuer = "Gatehouse" - - uri = TOTPService.generate_provisioning_uri(email, secret, issuer) - - assert email in uri - - def test_generate_provisioning_uri_contains_secret(self): - """Test that provisioning URI contains the secret.""" - email = "user@example.com" - secret = "JBSWY3DPEHPK3PXP" - issuer = "Gatehouse" - - uri = TOTPService.generate_provisioning_uri(email, secret, issuer) - - assert secret in uri - - def test_generate_provisioning_uri_contains_issuer(self): - """Test that provisioning URI contains the issuer.""" - email = "user@example.com" - secret = "JBSWY3DPEHPK3PXP" - issuer = "Gatehouse" - - uri = TOTPService.generate_provisioning_uri(email, secret, issuer) - - assert issuer in uri - - def test_generate_provisioning_uri_custom_issuer(self): - """Test that provisioning URI uses custom issuer.""" - email = "user@example.com" - secret = "JBSWY3DPEHPK3PXP" - custom_issuer = "MyApp" - - uri = TOTPService.generate_provisioning_uri(email, secret, custom_issuer) - - assert custom_issuer in uri - - # Test verify_code() - def test_verify_code_valid(self): - """Test that a valid TOTP code is accepted.""" - secret = TOTPService.generate_secret() - # Generate a valid code using pyotp - import pyotp - totp = pyotp.TOTP(secret) - valid_code = totp.now() - - result = TOTPService.verify_code(secret, valid_code) - - assert result is True - - def test_verify_code_invalid(self): - """Test that an invalid TOTP code is rejected.""" - secret = TOTPService.generate_secret() - invalid_code = "000000" - - result = TOTPService.verify_code(secret, invalid_code) - - assert result is False - - def test_verify_code_window_parameter(self): - """Test that the time window parameter works correctly.""" - secret = TOTPService.generate_secret() - import pyotp - totp = pyotp.TOTP(secret) - - # Get current code - current_code = totp.now() - - # Verify with window=1 (default) - should accept current code - result = TOTPService.verify_code(secret, current_code, window=1) - assert result is True - - # Verify with window=0 - should only accept exact time match - result = TOTPService.verify_code(secret, current_code, window=0) - assert result is True - - def test_verify_code_wrong_length(self): - """Test that codes with wrong length are rejected.""" - secret = TOTPService.generate_secret() - wrong_length_code = "12345" # 5 digits instead of 6 - - result = TOTPService.verify_code(secret, wrong_length_code) - - assert result is False - - # Test generate_backup_codes() - def test_generate_backup_codes_default_count(self): - """Test that generate_backup_codes generates 10 codes by default.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - assert len(plain_codes) == 10 - assert len(hashed_codes) == 10 - - def test_generate_backup_codes_custom_count(self): - """Test that generate_backup_codes generates the specified number of codes.""" - count = 5 - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count) - - assert len(plain_codes) == count - assert len(hashed_codes) == count - - def test_generate_backup_codes_plain_are_strings(self): - """Test that plain backup codes are strings.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - assert all(isinstance(code, str) for code in plain_codes) - - def test_generate_backup_codes_plain_length(self): - """Test that plain backup codes are 16 characters long.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - assert all(len(code) == 16 for code in plain_codes) - - def test_generate_backup_codes_hashed_different_from_plain(self): - """Test that hashed codes are different from plain codes.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - for plain, hashed in zip(plain_codes, hashed_codes): - assert plain != hashed - - def test_generate_backup_codes_are_bcrypt_hashes(self): - """Test that hashed codes are bcrypt hashes.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - # Bcrypt hashes start with $2a$, $2b$, or $2y$ - for hashed in hashed_codes: - assert hashed.startswith("$2") - - def test_generate_backup_codes_unique(self): - """Test that generated backup codes are unique.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes() - - assert len(set(plain_codes)) == len(plain_codes) - assert len(set(hashed_codes)) == len(hashed_codes) - - # Test verify_backup_code() - def test_verify_backup_code_valid(self): - """Test that a valid backup code is accepted and removed.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count=3) - code_to_verify = plain_codes[0] - - is_valid, remaining_codes = TOTPService.verify_backup_code(hashed_codes, code_to_verify) - - assert is_valid is True - assert len(remaining_codes) == 2 - - def test_verify_backup_code_invalid(self): - """Test that an invalid backup code is rejected.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count=3) - invalid_code = "INVALIDCODE1234" - - is_valid, remaining_codes = TOTPService.verify_backup_code(hashed_codes, invalid_code) - - assert is_valid is False - assert len(remaining_codes) == 3 - - def test_verify_backup_code_remaining_updated(self): - """Test that the remaining codes list is updated correctly.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count=5) - code_to_verify = plain_codes[2] - - is_valid, remaining_codes = TOTPService.verify_backup_code(hashed_codes, code_to_verify) - - assert is_valid is True - # The verified code should be removed - assert len(remaining_codes) == 4 - # The remaining codes should not include the verified code's hash - assert hashed_codes[2] not in remaining_codes - - def test_verify_backup_code_case_sensitive(self): - """Test that backup code verification is case sensitive.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count=1) - code_to_verify = plain_codes[0].lower() # Convert to lowercase - - is_valid, remaining_codes = TOTPService.verify_backup_code(hashed_codes, code_to_verify) - - assert is_valid is False - assert len(remaining_codes) == 1 - - def test_verify_backup_code_single_use(self): - """Test that a backup code can only be used once.""" - plain_codes, hashed_codes = TOTPService.generate_backup_codes(count=1) - code_to_verify = plain_codes[0] - - # First use - should succeed - is_valid1, remaining1 = TOTPService.verify_backup_code(hashed_codes, code_to_verify) - assert is_valid1 is True - assert len(remaining1) == 0 - - # Second use - should fail (code already consumed) - is_valid2, remaining2 = TOTPService.verify_backup_code(remaining1, code_to_verify) - assert is_valid2 is False - assert len(remaining2) == 0 - - # Test generate_qr_code_data_uri() - def test_generate_qr_code_data_uri_format(self): - """Test that a data URI is generated.""" - provisioning_uri = "otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse" - - data_uri = TOTPService.generate_qr_code_data_uri(provisioning_uri) - - assert isinstance(data_uri, str) - - def test_generate_qr_code_data_uri_starts_with_prefix(self): - """Test that the data URI starts with the correct prefix.""" - provisioning_uri = "otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse" - - data_uri = TOTPService.generate_qr_code_data_uri(provisioning_uri) - - assert data_uri.startswith("data:image/png;base64,") - - def test_generate_qr_code_data_uri_contains_base64(self): - """Test that the data URI contains base64 encoded data.""" - provisioning_uri = "otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse" - - data_uri = TOTPService.generate_qr_code_data_uri(provisioning_uri) - - # Extract the base64 part (after the prefix) - base64_part = data_uri.split("data:image/png;base64,")[1] - - # Verify it's valid base64 - try: - base64.b64decode(base64_part) - assert True - except Exception: - assert False, "Data URI does not contain valid base64 data" - - def test_generate_qr_code_data_uri_different_uris(self): - """Test that different provisioning URIs generate different QR codes.""" - uri1 = "otpauth://totp/Gatehouse:user1@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse" - uri2 = "otpauth://totp/Gatehouse:user2@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse" - - data_uri1 = TOTPService.generate_qr_code_data_uri(uri1) - data_uri2 = TOTPService.generate_qr_code_data_uri(uri2) - - assert data_uri1 != data_uri2