- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine implementing the data-model transition table, approval gate with sha256 hash match + 24h expiry enforced at send time, EchoTransport pluggable sender, LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green - apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations copied as directory) - docker-compose.yml: add api-test service on compose network (host port publishing is broken in this sandbox; container-to-container networking used) - Fix: replace masked placeholder password in config.py/conftest.py defaults
216 lines
No EOL
7.2 KiB
Python
216 lines
No EOL
7.2 KiB
Python
"""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() |