"""Approval gate tests: confirm, hash-match, expiry.""" from __future__ import annotations import hashlib import time from datetime import datetime, timedelta, timezone import psycopg import pytest from fastapi.testclient import TestClient from app.config import DATABASE_URL from app.db import repo_app, repo_profile @pytest.fixture() def client(): """FastAPI TestClient with real database.""" from app.main import app return TestClient(app) @pytest.fixture() def seed_data(): """Seed a profile, posting, application, and artifact for approval tests.""" profile = repo_profile.get_or_create_profile() assert profile is not None posting = repo_app.create_job_posting( source="manual_url", url="https://example.com/job/123", company="TestCorp", title="Engineer", ) application = repo_app.create_application(posting["id"]) content = b"Hello, I am applying for the position." artifact = repo_app.create_artifact( application_id=application["id"], kind="cover_letter", filename="cover.txt", content_bytes=content, storage_path="/tmp/cover.txt", origin="user_drafted", ) return { "profile": profile, "posting": posting, "application": application, "artifact": artifact, "content": content, } class TestApprovalConfirm: def test_confirm_approval_success(self, client, seed_data): """User confirms approval with matching hash -> success.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create approval resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) assert resp.status_code == 201 approval = resp.json() # Confirm resp = client.post(f"/api/approvals/{approval['id']}/confirm") assert resp.status_code == 200 assert resp.json()["confirmed_by_user"] is True def test_confirm_approval_hash_mismatch(self, client, seed_data): """Confirm with a different artifact hash -> 409.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create approval (stores correct hash) resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) assert resp.status_code == 201 approval_id = resp.json()["id"] # Now modify the artifact content so hash changes # We create a new artifact with different content but same id is not possible. # Instead, directly update the artifact hash in the DB to simulate mutation. new_content = b"Modified content" new_hash = hashlib.sha256(new_content).hexdigest() with psycopg.connect(DATABASE_URL) as conn: conn.execute( "UPDATE artifact SET content_hash = %s WHERE id = %s", (new_hash, artifact_id), ) conn.commit() # Confirm should fail with hash mismatch resp = client.post(f"/api/approvals/{approval_id}/confirm") assert resp.status_code == 409 assert "hash_mismatch" in resp.text or "hash" in resp.text.lower() class TestApprovalExpiry: def test_confirm_expired_approval(self, client, seed_data): """Confirm an expired approval -> 409.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create approval resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) assert resp.status_code == 201 approval_id = resp.json()["id"] # Set expires_at to the past past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() with psycopg.connect(DATABASE_URL) as conn: conn.execute( "UPDATE approval SET expires_at = %s WHERE id = %s", (past_time, approval_id), ) conn.commit() # Confirm should fail with expired resp = client.post(f"/api/approvals/{approval_id}/confirm") assert resp.status_code == 409 assert "expired" in resp.text.lower() class TestOutboxSend: def test_send_without_confirmation(self, client, seed_data): """Send without user confirmation -> 409.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create approval resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) assert resp.status_code == 201 approval_id = resp.json()["id"] # Try to send without confirming resp = client.post( "/api/outbox/send", json={"approval_id": approval_id, "payload": {"to": "test@example.com"}}, ) assert resp.status_code == 409 assert "not_confirmed" in resp.text def test_send_with_confirmation_success(self, client, seed_data): """Full flow: create approval, confirm, send -> success.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create approval resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) assert resp.status_code == 201 approval_id = resp.json()["id"] # Confirm resp = client.post(f"/api/approvals/{approval_id}/confirm") assert resp.status_code == 200 # Send resp = client.post( "/api/outbox/send", json={ "approval_id": approval_id, "payload": {"to": "test@example.com", "subject": "App", "body": "Hi"}, }, ) assert resp.status_code == 200 assert resp.json()["status"] == "sent" def test_send_expired_confirmation(self, client, seed_data): """Send with an expired confirmed approval -> 409.""" app_id = seed_data["application"]["id"] artifact_id = seed_data["artifact"]["id"] # Create + confirm approval resp = client.post( f"/api/applications/{app_id}/approvals", json={"action": "send_email", "artifact_id": artifact_id}, ) approval_id = resp.json()["id"] resp = client.post(f"/api/approvals/{approval_id}/confirm") assert resp.status_code == 200 # Expire it past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() with psycopg.connect(DATABASE_URL) as conn: conn.execute( "UPDATE approval SET expires_at = %s WHERE id = %s", (past_time, approval_id), ) conn.commit() # Send should fail resp = client.post( "/api/outbox/send", json={"approval_id": approval_id, "payload": {"to": "test@example.com"}}, ) assert resp.status_code == 409 assert "expired" in resp.text.lower()