Implement service authentication and error handling; update environment variables and documentation
This commit is contained in:
parent
5ab10cf6c8
commit
9913ec5241
14
.env.example
14
.env.example
@ -24,11 +24,12 @@ SECRET_PROVIDER=infisical # fake | google | infisical
|
|||||||
|
|
||||||
# Infisical
|
# Infisical
|
||||||
INFISICAL_HOST=https://eu.infisical.com
|
INFISICAL_HOST=https://eu.infisical.com
|
||||||
INFISICAL_CLIENT_ID=35f83820-a9d3-4622-a0ab-ae6170f662fa
|
INFISICAL_CLIENT_ID=replace-with-infisical-client-id
|
||||||
INFISICAL_CLIENT_SECRET=2c158d6f77fe4fc684bdb78d0c1ed21ed7a762885f508facc575aa05c42397c5
|
INFISICAL_CLIENT_SECRET=replace-with-infisical-client-secret
|
||||||
INFISICAL_PROJECT_ID=0bd3c2e0-39f5-4f92-8c6e-49bcc7eda896
|
INFISICAL_PROJECT_ID=replace-with-infisical-project-id
|
||||||
INFISICAL_ENV_SLUG=dev
|
INFISICAL_ENV_SLUG=dev
|
||||||
INFISICAL_TOKEN_AUTH=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiMmNjYmRkZmMtMGQyOC00ZmIwLTlhMGEtNWQyYTZiZWU3YzliIiwiaWRlbnRpdHlBY2Nlc3NUb2tlbklkIjoiZWMzNjg1OGUtNTkzYi00ZTE3LWI2ZmItN2NhODdlZDBhMmZhIiwiYXV0aFRva2VuVHlwZSI6ImlkZW50aXR5QWNjZXNzVG9rZW4iLCJpYXQiOjE3Njk2MTQ3NzQsImV4cCI6MTc3MjIwNjc3NH0.KZvzabbsgevhukPfc8LSlkOwos5eBaGvaqC2XPJEGHI
|
# Reserved for backwards compatibility; it is not used by the current SDK adapter.
|
||||||
|
INFISICAL_TOKEN_AUTH=replace-with-token-if-your-deployment-requires-it
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
GCP_PROJECT_ID=
|
GCP_PROJECT_ID=
|
||||||
@ -38,3 +39,8 @@ GCP_PROJECT_ID=
|
|||||||
# --------------------
|
# --------------------
|
||||||
CERT_SECRET_NAME=certificate_secret_name
|
CERT_SECRET_NAME=certificate_secret_name
|
||||||
CERT_PASSWORD_SECRET_NAME=certificate_password_secret_name
|
CERT_PASSWORD_SECRET_NAME=certificate_password_secret_name
|
||||||
|
# Technical client authentication for POST /documents/sign. Send it as:
|
||||||
|
# Authorization: Bearer <SERVICE_API_KEY>
|
||||||
|
SERVICE_API_KEY=replace-with-a-long-random-service-api-key
|
||||||
|
# Maximum accepted upload size. The service reads at most this value plus one byte.
|
||||||
|
PDF_MAX_SIZE_BYTES=10485760
|
||||||
|
|||||||
16
README.md
16
README.md
@ -0,0 +1,16 @@
|
|||||||
|
# FactuGES Document Signing Service
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
`POST /documents/sign` requires a technical credential configured in
|
||||||
|
`SERVICE_API_KEY` and sent as `Authorization: Bearer <SERVICE_API_KEY>`.
|
||||||
|
`GET /health` and `GET /version` remain public. Set `PDF_MAX_SIZE_BYTES` to
|
||||||
|
limit in-memory PDF uploads (default: 10 MiB).
|
||||||
|
|
||||||
|
The multipart fields `certificate_secret_name`,
|
||||||
|
`certificate_password_secret_name`, and `company_slug` remain required for
|
||||||
|
backwards compatibility. Certificate selection continues to use
|
||||||
|
`CERT_SECRET_NAME` and `CERT_PASSWORD_SECRET_NAME`.
|
||||||
|
|
||||||
|
Any Infisical credentials that were previously committed must be revoked or
|
||||||
|
rotated in Infisical outside this repository.
|
||||||
@ -29,6 +29,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
|
"httpx",
|
||||||
"ruff",
|
"ruff",
|
||||||
"mypy",
|
"mypy",
|
||||||
]
|
]
|
||||||
29
src/signing_service/api/authentication.py
Normal file
29
src/signing_service/api/authentication.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Authentication dependencies for technical API consumers."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from signing_service.application.errors import (
|
||||||
|
AuthenticationRequiredError,
|
||||||
|
InvalidCredentialsError,
|
||||||
|
)
|
||||||
|
from signing_service.application.settings.container import get_settings
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_service_authentication(
|
||||||
|
credentials: Annotated[
|
||||||
|
HTTPAuthorizationCredentials | None, Depends(_bearer)
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Require a configured Bearer token without exposing supplied credentials."""
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise AuthenticationRequiredError()
|
||||||
|
|
||||||
|
expected = get_settings().service_api_key
|
||||||
|
if not secrets.compare_digest(credentials.credentials, expected):
|
||||||
|
raise InvalidCredentialsError()
|
||||||
@ -1,59 +1,53 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from signing_service.api.authentication import require_service_authentication
|
||||||
|
from signing_service.application.errors import PayloadTooLargeError
|
||||||
from signing_service.application.settings.container import get_settings
|
from signing_service.application.settings.container import get_settings
|
||||||
|
from signing_service.application.use_cases.sign_document import SignDocumentUseCase
|
||||||
from signing_service.application.use_cases.sign_document import (
|
from signing_service.application.use_cases.sign_document_dto import SignDocumentCommand
|
||||||
SignDocumentUseCase,
|
from signing_service.infrastructure.factories.pdf_signer_factory import (
|
||||||
)
|
create_pdf_signer,
|
||||||
from signing_service.application.use_cases.sign_document_dto import (
|
|
||||||
SignDocumentCommand,
|
|
||||||
)
|
)
|
||||||
from signing_service.infrastructure.factories.secret_manager_factory import (
|
from signing_service.infrastructure.factories.secret_manager_factory import (
|
||||||
create_secret_manager,
|
create_secret_manager,
|
||||||
)
|
)
|
||||||
from signing_service.infrastructure.factories.pdf_signer_factory import (
|
|
||||||
create_pdf_signer,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sign")
|
@router.post("/sign")
|
||||||
async def sign_document(
|
async def sign_document(
|
||||||
file: UploadFile = File(...),
|
file: Annotated[UploadFile, File(...)],
|
||||||
certificate_secret_name: str = Form(...),
|
certificate_secret_name: Annotated[str, Form(...)],
|
||||||
certificate_password_secret_name: str = Form(...),
|
certificate_password_secret_name: Annotated[str, Form(...)],
|
||||||
company_slug: str = Form(...),
|
company_slug: Annotated[str, Form(...)],
|
||||||
|
_: Annotated[None, Depends(require_service_authentication)],
|
||||||
):
|
):
|
||||||
try:
|
"""Sign with the globally configured legacy certificate selection."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
pdf_bytes = await file.read(settings.pdf_max_size_bytes + 1)
|
||||||
|
if len(pdf_bytes) > settings.pdf_max_size_bytes:
|
||||||
|
raise PayloadTooLargeError()
|
||||||
|
|
||||||
pdf_bytes = await file.read()
|
use_case = SignDocumentUseCase(
|
||||||
|
secret_manager=create_secret_manager(),
|
||||||
|
pdf_signer=create_pdf_signer(),
|
||||||
|
cert_secret_name=settings.pdf_cert_secret_name,
|
||||||
|
cert_password_secret_name=settings.pdf_cert_password_secret_name,
|
||||||
|
)
|
||||||
|
command = SignDocumentCommand(
|
||||||
|
pdf_bytes=pdf_bytes,
|
||||||
|
certificate_secret_name=certificate_secret_name,
|
||||||
|
certificate_password_secret_name=certificate_password_secret_name,
|
||||||
|
company_slug=company_slug,
|
||||||
|
)
|
||||||
|
result = await use_case.execute(command)
|
||||||
|
|
||||||
use_case = SignDocumentUseCase(
|
return Response(
|
||||||
secret_manager=create_secret_manager(),
|
content=result.signed_pdf_bytes,
|
||||||
pdf_signer=create_pdf_signer(),
|
media_type="application/pdf",
|
||||||
cert_secret_name=settings.pdf_cert_secret_name,
|
headers={"Content-Disposition": f"attachment; filename=signed_{file.filename}"},
|
||||||
cert_password_secret_name=settings.pdf_cert_password_secret_name,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
command = SignDocumentCommand(
|
|
||||||
pdf_bytes=pdf_bytes,
|
|
||||||
certificate_secret_name=certificate_secret_name,
|
|
||||||
certificate_password_secret_name=certificate_password_secret_name,
|
|
||||||
company_slug=company_slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await use_case.execute(command)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
content=result.signed_pdf_bytes,
|
|
||||||
media_type="application/pdf",
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f"attachment; filename=signed_{file.filename}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
|
||||||
|
|||||||
43
src/signing_service/application/errors.py
Normal file
43
src/signing_service/application/errors.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Stable, safe errors exposed by the signing service."""
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceError(Exception):
|
||||||
|
code = "SIGNING_FAILED"
|
||||||
|
status_code = 500
|
||||||
|
message = "Document signing failed"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationRequiredError(ServiceError):
|
||||||
|
code = "AUTHENTICATION_REQUIRED"
|
||||||
|
status_code = 401
|
||||||
|
message = "Authentication is required"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCredentialsError(ServiceError):
|
||||||
|
code = "INVALID_CREDENTIALS"
|
||||||
|
status_code = 401
|
||||||
|
message = "Invalid credentials"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRequestError(ServiceError):
|
||||||
|
code = "INVALID_REQUEST"
|
||||||
|
status_code = 400
|
||||||
|
message = "Invalid request"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPdfError(ServiceError):
|
||||||
|
code = "INVALID_PDF"
|
||||||
|
status_code = 400
|
||||||
|
message = "Invalid PDF document"
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadTooLargeError(ServiceError):
|
||||||
|
code = "INVALID_REQUEST"
|
||||||
|
status_code = 413
|
||||||
|
message = "PDF exceeds the configured maximum size"
|
||||||
|
|
||||||
|
|
||||||
|
class SecretProviderError(ServiceError):
|
||||||
|
code = "SECRET_PROVIDER_ERROR"
|
||||||
|
status_code = 503
|
||||||
|
message = "Secret provider is unavailable"
|
||||||
@ -10,6 +10,8 @@ class AppSettings:
|
|||||||
log_level: str
|
log_level: str
|
||||||
state_path: str
|
state_path: str
|
||||||
local_tz: str
|
local_tz: str
|
||||||
|
service_api_key: str
|
||||||
|
pdf_max_size_bytes: int
|
||||||
|
|
||||||
# --------------------
|
# --------------------
|
||||||
# Secret manager
|
# Secret manager
|
||||||
|
|||||||
@ -56,6 +56,16 @@ def load_app_settings() -> AppSettings:
|
|||||||
default="UTC",
|
default="UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service_api_key = get_env("SERVICE_API_KEY", required=True)
|
||||||
|
|
||||||
|
pdf_max_size_raw = get_env("PDF_MAX_SIZE_BYTES", default="10485760")
|
||||||
|
try:
|
||||||
|
pdf_max_size_bytes = int(pdf_max_size_raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("PDF_MAX_SIZE_BYTES must be a positive integer") from exc
|
||||||
|
if pdf_max_size_bytes <= 0:
|
||||||
|
raise ValueError("PDF_MAX_SIZE_BYTES must be a positive integer")
|
||||||
|
|
||||||
# --------------------
|
# --------------------
|
||||||
# Secret manager
|
# Secret manager
|
||||||
# --------------------
|
# --------------------
|
||||||
@ -96,10 +106,8 @@ def load_app_settings() -> AppSettings:
|
|||||||
required=secret_provider == "infisical",
|
required=secret_provider == "infisical",
|
||||||
)
|
)
|
||||||
|
|
||||||
infisical_token_auth = get_env(
|
# Kept for configuration compatibility. The current universal-auth adapter does not use it.
|
||||||
"INFISICAL_TOKEN_AUTH",
|
infisical_token_auth = get_env("INFISICAL_TOKEN_AUTH")
|
||||||
required=secret_provider == "infisical",
|
|
||||||
)
|
|
||||||
|
|
||||||
gcp_project_id = get_env(
|
gcp_project_id = get_env(
|
||||||
"GCP_PROJECT_ID",
|
"GCP_PROJECT_ID",
|
||||||
@ -127,6 +135,8 @@ def load_app_settings() -> AppSettings:
|
|||||||
log_level=log_level,
|
log_level=log_level,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
local_tz=local_tz,
|
local_tz=local_tz,
|
||||||
|
service_api_key=service_api_key,
|
||||||
|
pdf_max_size_bytes=pdf_max_size_bytes,
|
||||||
secret_provider=secret_provider,
|
secret_provider=secret_provider,
|
||||||
infisical_host=infisical_host,
|
infisical_host=infisical_host,
|
||||||
infisical_client_id=infisical_client_id,
|
infisical_client_id=infisical_client_id,
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
from signing_service.application.errors import SecretProviderError, ServiceError
|
||||||
from signing_service.application.use_cases.sign_document_dto import (
|
from signing_service.application.use_cases.sign_document_dto import (
|
||||||
SignDocumentCommand,
|
SignDocumentCommand,
|
||||||
SignDocumentResult,
|
SignDocumentResult,
|
||||||
)
|
)
|
||||||
from signing_service.domain.ports.secret_manager import SecretManagerPort
|
|
||||||
from signing_service.domain.ports.pdf_signer import PDFSignerPort
|
from signing_service.domain.ports.pdf_signer import PDFSignerPort
|
||||||
|
from signing_service.domain.ports.secret_manager import SecretManagerPort
|
||||||
|
from signing_service.infrastructure.pdf.pdf_validator_error import PDFValidationError
|
||||||
|
|
||||||
|
|
||||||
class SignDocumentUseCase:
|
class SignDocumentUseCase:
|
||||||
@ -20,18 +22,25 @@ class SignDocumentUseCase:
|
|||||||
self._cert_password_secret_name = cert_password_secret_name
|
self._cert_password_secret_name = cert_password_secret_name
|
||||||
|
|
||||||
async def execute(self, command: SignDocumentCommand) -> SignDocumentResult:
|
async def execute(self, command: SignDocumentCommand) -> SignDocumentResult:
|
||||||
certificate = self._secret_manager.get_secret(
|
try:
|
||||||
self._cert_secret_name
|
certificate = self._secret_manager.get_secret(self._cert_secret_name)
|
||||||
)
|
password = self._secret_manager.get_secret(self._cert_password_secret_name)
|
||||||
|
except Exception as exc:
|
||||||
|
raise SecretProviderError() from exc
|
||||||
|
|
||||||
password = self._secret_manager.get_secret(
|
try:
|
||||||
self._cert_password_secret_name
|
signed_pdf = await self._pdf_signer.sign(
|
||||||
)
|
pdf_bytes=command.pdf_bytes,
|
||||||
|
certificate=certificate,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
except PDFValidationError as exc:
|
||||||
|
from signing_service.application.errors import InvalidPdfError
|
||||||
|
|
||||||
signed_pdf = await self._pdf_signer.sign(
|
raise InvalidPdfError() from exc
|
||||||
pdf_bytes=command.pdf_bytes,
|
except ServiceError:
|
||||||
certificate=certificate,
|
raise
|
||||||
password=password,
|
except Exception as exc:
|
||||||
)
|
raise ServiceError() from exc
|
||||||
|
|
||||||
return SignDocumentResult(signed_pdf_bytes=signed_pdf)
|
return SignDocumentResult(signed_pdf_bytes=signed_pdf)
|
||||||
@ -22,9 +22,6 @@ def create_secret_manager() -> SecretManagerPort:
|
|||||||
if not settings.infisical_project_id:
|
if not settings.infisical_project_id:
|
||||||
raise ValueError("INFISICAL_PROJECT_ID is required for Infisical Secret Manager")
|
raise ValueError("INFISICAL_PROJECT_ID is required for Infisical Secret Manager")
|
||||||
|
|
||||||
if not settings.infisical_token_auth:
|
|
||||||
raise ValueError("INFISICAL_TOKEN_AUTH is required for Infisical Secret Manager")
|
|
||||||
|
|
||||||
if not settings.infisical_env_slug:
|
if not settings.infisical_env_slug:
|
||||||
raise ValueError("INFISICAL_ENV_SLUG is required for Infisical Secret Manager")
|
raise ValueError("INFISICAL_ENV_SLUG is required for Infisical Secret Manager")
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,7 @@ from signing_service.domain.ports.pdf_signer import PDFSignerPort
|
|||||||
|
|
||||||
|
|
||||||
class FakePDFSigner(PDFSignerPort):
|
class FakePDFSigner(PDFSignerPort):
|
||||||
"""
|
"""Test/development signer that never includes secret material in output."""
|
||||||
Fake implementation of PDFSignerPort.
|
|
||||||
It does NOT sign a real PDF.
|
|
||||||
It only simulates the behavior for testing and development.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def sign(self, pdf_bytes: bytes, certificate: str, password: str) -> bytes:
|
async def sign(self, pdf_bytes: bytes, certificate: str, password: str) -> bytes:
|
||||||
# Simulación simple:
|
return pdf_bytes + b"\n\n-- Signed by fake signer"
|
||||||
# añadimos un marcador para indicar "firmado"
|
|
||||||
signature_marker = f"\n\n-- Signed with certificate: {certificate} and password: {password}".encode()
|
|
||||||
|
|
||||||
return pdf_bytes + signature_marker
|
|
||||||
|
|||||||
@ -1,133 +1,121 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from signing_service.api.routes.sign_document import router as sign_router
|
||||||
from pathlib import Path
|
from signing_service.application.errors import InvalidRequestError, ServiceError
|
||||||
from datetime import datetime, UTC
|
|
||||||
from dateutil import tz
|
|
||||||
|
|
||||||
from signing_service.application.settings.container import get_settings
|
from signing_service.application.settings.container import get_settings
|
||||||
from signing_service.application.settings.setup_logger import create_logger
|
from signing_service.application.settings.setup_logger import create_logger
|
||||||
from signing_service.api.routes.sign_document import router as sign_router
|
|
||||||
from signing_service.application.settings.version import get_package_version
|
from signing_service.application.settings.version import get_package_version
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# 👇 FAIL FAST: load settings at startup
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
version = get_package_version()
|
version = get_package_version()
|
||||||
|
|
||||||
|
|
||||||
def build_logger():
|
def build_logger():
|
||||||
"""Crea el logger de aplicación y asegura el directorio de logs."""
|
|
||||||
log_dir = Path(settings.state_path)
|
log_dir = Path(settings.state_path)
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
app_logger = create_logger(
|
app_logger = create_logger(
|
||||||
name="factuges-document-signing-service",
|
name="factuges-document-signing-service",
|
||||||
log_path=log_dir / "factuges-document-signing-service.log",
|
log_path=log_dir / "factuges-document-signing-service.log",
|
||||||
)
|
)
|
||||||
|
app_logger.info("FactuGES Document Signing Service started")
|
||||||
app_logger.info("")
|
|
||||||
app_logger.info(
|
|
||||||
"============================================================")
|
|
||||||
app_logger.info("FactuGES Document Signing Service - START")
|
|
||||||
app_logger.info("Version: %s", version)
|
app_logger.info("Version: %s", version)
|
||||||
app_logger.info("UTC Now: %s", datetime.now(UTC).isoformat())
|
app_logger.info("UTC Now: %s", datetime.now(UTC).isoformat())
|
||||||
app_logger.info("Environment: %s", settings.app_env)
|
app_logger.info("Environment: %s", settings.app_env)
|
||||||
app_logger.info("")
|
app_logger.info("Secret provider configured: %s", settings.secret_provider)
|
||||||
app_logger.info("Log Level: %s", settings.log_level)
|
|
||||||
app_logger.info(
|
|
||||||
"Log: %s",
|
|
||||||
log_dir / "factuges-document-signing-service.log",
|
|
||||||
)
|
|
||||||
app_logger.info("")
|
|
||||||
app_logger.info("Secret Provider: %s", settings.secret_provider)
|
|
||||||
app_logger.info("Infisical project ID: %s", settings.infisical_project_id)
|
|
||||||
app_logger.info("Infisical environment: %s", settings.infisical_env_slug)
|
|
||||||
app_logger.info("")
|
|
||||||
|
|
||||||
return app_logger
|
return app_logger
|
||||||
|
|
||||||
|
|
||||||
logger = build_logger()
|
logger = build_logger()
|
||||||
|
|
||||||
|
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
def error_response(error: ServiceError) -> JSONResponse:
|
||||||
"""Registra y devuelve errores HTTP controlados."""
|
return JSONResponse(
|
||||||
logger.error(
|
status_code=error.status_code,
|
||||||
"HTTPException %s %s | status=%s | detail=%s",
|
content={"error": {"code": error.code, "message": error.message}},
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
exc.status_code,
|
|
||||||
exc.detail,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
async def service_error_handler(request: Request, exc: ServiceError) -> JSONResponse:
|
||||||
content={"detail": exc.detail},
|
logger.warning(
|
||||||
|
"Service error method=%s path=%s code=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
exc.code,
|
||||||
)
|
)
|
||||||
|
return error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def validation_error_handler(
|
||||||
|
request: Request, exc: RequestValidationError
|
||||||
|
) -> JSONResponse:
|
||||||
|
logger.warning("Invalid request method=%s path=%s", request.method, request.url.path)
|
||||||
|
return error_response(InvalidRequestError())
|
||||||
|
|
||||||
|
|
||||||
|
async def unexpected_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.error(
|
||||||
|
"Unexpected signing-service error method=%s path=%s type=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
return error_response(ServiceError())
|
||||||
|
|
||||||
|
|
||||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
"""Registra request y response para trazabilidad básica."""
|
"""Logs only request metadata; never headers or bodies."""
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
method = request.method
|
logger.info("Request: %s %s from %s", request.method, request.url.path, client_ip)
|
||||||
url = request.url.path
|
|
||||||
|
|
||||||
logger.info("Request: %s %s from %s", method, url, client_ip)
|
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Response: %s %s returned %s to %s",
|
"Response: %s %s returned %s to %s",
|
||||||
method,
|
request.method,
|
||||||
url,
|
request.url.path,
|
||||||
response.status_code,
|
response.status_code,
|
||||||
client_ip,
|
client_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Gestiona el ciclo de vida de la aplicación."""
|
|
||||||
logger.info("Starting signing service")
|
logger.info("Starting signing service")
|
||||||
yield
|
yield
|
||||||
logger.info("Stopping signing service")
|
logger.info("Stopping signing service")
|
||||||
|
|
||||||
|
|
||||||
async def healthcheck() -> dict[str, str]:
|
async def healthcheck() -> dict[str, str]:
|
||||||
"""Endpoint simple de healthcheck."""
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def get_version() -> dict[str, str]:
|
async def get_version() -> dict[str, str]:
|
||||||
"""Devuelve la versión publicada del servicio."""
|
|
||||||
return {"version": version}
|
return {"version": version}
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(lifespan=lifespan,
|
app = FastAPI(
|
||||||
title="FactuGES Document Signing Service",
|
lifespan=lifespan,
|
||||||
version=version)
|
title="FactuGES Document Signing Service",
|
||||||
|
version=version,
|
||||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
)
|
||||||
|
app.add_exception_handler(ServiceError, service_error_handler)
|
||||||
|
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
||||||
|
app.add_exception_handler(Exception, unexpected_error_handler)
|
||||||
app.add_middleware(LoggingMiddleware)
|
app.add_middleware(LoggingMiddleware)
|
||||||
app.include_router(sign_router)
|
app.include_router(sign_router)
|
||||||
logger.info("API routes registered from sign_document router")
|
|
||||||
|
|
||||||
app.add_api_route("/health", healthcheck, methods=["GET"])
|
app.add_api_route("/health", healthcheck, methods=["GET"])
|
||||||
logger.info("Health check endpoint registered at /health")
|
|
||||||
|
|
||||||
app.add_api_route("/version", get_version, methods=["GET"])
|
app.add_api_route("/version", get_version, methods=["GET"])
|
||||||
logger.info("Version endpoint registered at /version")
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,77 +1,53 @@
|
|||||||
|
"""Opt-in, safe Infisical connectivity smoke test.
|
||||||
|
|
||||||
|
This utility never prints credentials, secret values, full secret objects, or provider
|
||||||
|
exception messages. It is not part of the automated test suite.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from infisical_sdk import InfisicalSDKClient
|
from infisical_sdk import InfisicalSDKClient
|
||||||
|
|
||||||
from signing_service.application.settings.container import get_settings
|
from signing_service.application.settings.container import get_settings
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
secret_name = os.environ.get("INFISICAL_SECRET_NAME", "pdf_cert_password")
|
secret_name = os.environ.get("INFISICAL_SECRET_NAME")
|
||||||
secret_path = os.environ.get("INFISICAL_SECRET_PATH", "/rodax")
|
secret_path = os.environ.get("INFISICAL_SECRET_PATH", "/rodax")
|
||||||
|
required = {
|
||||||
missing = [k for k, v in {
|
"INFISICAL_CLIENT_ID": settings.infisical_client_id,
|
||||||
"INFISICAL_SERVICE_TOKEN": settings.infisical_token_auth,
|
"INFISICAL_CLIENT_SECRET": settings.infisical_client_secret,
|
||||||
"INFISICAL_PROJECT_ID": settings.infisical_project_id,
|
"INFISICAL_PROJECT_ID": settings.infisical_project_id,
|
||||||
}.items() if not v]
|
"INFISICAL_ENV_SLUG": settings.infisical_env_slug,
|
||||||
|
"INFISICAL_SECRET_NAME": secret_name,
|
||||||
|
}
|
||||||
|
missing = [name for name, value in required.items() if not value]
|
||||||
if missing:
|
if missing:
|
||||||
print(f"Missing env vars: {', '.join(missing)}", file=sys.stderr)
|
print("Missing required configuration", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
print("== Infisical smoke test ==")
|
|
||||||
|
|
||||||
print(f"host=https://eu.infisical.com")
|
|
||||||
|
|
||||||
print(f"client_id={settings.infisical_client_id}")
|
|
||||||
print(f"client_secret={settings.infisical_client_secret}")
|
|
||||||
print(f"project_id={settings.infisical_project_id}")
|
|
||||||
print(f"environment={settings.infisical_env_slug}")
|
|
||||||
print(f"secret_path={secret_path}")
|
|
||||||
print(f"secret_name={secret_name}")
|
|
||||||
|
|
||||||
client = InfisicalSDKClient(host="https://eu.infisical.com", token=None)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1) Login
|
client = InfisicalSDKClient(host=settings.infisical_host, token=None)
|
||||||
#client.auth.token_auth.login(
|
|
||||||
# token=settings.infisical_token_auth
|
|
||||||
#)
|
|
||||||
|
|
||||||
client.auth.universal_auth.login(
|
client.auth.universal_auth.login(
|
||||||
client_id=settings.infisical_client_id,
|
client_id=settings.infisical_client_id,
|
||||||
client_secret=settings.infisical_client_secret
|
client_secret=settings.infisical_client_secret,
|
||||||
)
|
)
|
||||||
|
client.secrets.get_secret_by_name(
|
||||||
print("OK: token_auth.login")
|
|
||||||
|
|
||||||
# 2) Read one secret (the real test)
|
|
||||||
secret = client.secrets.get_secret_by_name(
|
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
project_id=settings.infisical_project_id,
|
project_id=settings.infisical_project_id,
|
||||||
environment_slug=settings.infisical_env_slug,
|
environment_slug=settings.infisical_env_slug,
|
||||||
secret_path=secret_path,
|
secret_path=secret_path,
|
||||||
view_secret_value=True,
|
view_secret_value=True,
|
||||||
)
|
)
|
||||||
|
print("Infisical connection and secret retrieval succeeded")
|
||||||
print(secret)
|
|
||||||
|
|
||||||
value = getattr(secret, "secretValue", None)
|
|
||||||
if not value:
|
|
||||||
print("ERROR: Secret read returned empty value", file=sys.stderr)
|
|
||||||
return 3
|
|
||||||
|
|
||||||
print("OK: get_secret_by_name")
|
|
||||||
print(f"OK: secret_value_length={len(value)}")
|
|
||||||
return 0
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 - SDK failure types are not stable
|
||||||
except Exception as e:
|
print(f"Infisical smoke test failed ({type(exc).__name__})", file=sys.stderr)
|
||||||
# Print the most useful info without dumping secrets
|
|
||||||
print("ERROR: Infisical smoke test failed", file=sys.stderr)
|
|
||||||
print(f"{type(e).__name__}: {e}", file=sys.stderr)
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
39
tests/api/test_authentication.py
Normal file
39
tests/api/test_authentication.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from signing_service.api import authentication
|
||||||
|
from signing_service.application.errors import (
|
||||||
|
AuthenticationRequiredError,
|
||||||
|
InvalidCredentialsError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials:
|
||||||
|
def __init__(self, value: str, scheme: str = "Bearer") -> None:
|
||||||
|
self.credentials = value
|
||||||
|
self.scheme = scheme
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_credential_is_rejected() -> None:
|
||||||
|
with pytest.raises(AuthenticationRequiredError):
|
||||||
|
asyncio.run(authentication.require_service_authentication(None))
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_credential_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
authentication, "get_settings", lambda: SimpleNamespace(service_api_key="valid")
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidCredentialsError):
|
||||||
|
asyncio.run(authentication.require_service_authentication(Credentials("wrong")))
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_credential_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
authentication, "get_settings", lambda: SimpleNamespace(service_api_key="valid")
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
asyncio.run(authentication.require_service_authentication(Credentials("valid")))
|
||||||
|
is None
|
||||||
|
)
|
||||||
101
tests/api/test_http.py
Normal file
101
tests/api/test_http.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||||
|
monkeypatch.setenv("APP_ENV", "local")
|
||||||
|
monkeypatch.setenv("SECRET_PROVIDER", "fake")
|
||||||
|
monkeypatch.setenv("CERT_SECRET_NAME", "pdf-signing-cert")
|
||||||
|
monkeypatch.setenv("CERT_PASSWORD_SECRET_NAME", "pdf-signing-cert-password")
|
||||||
|
monkeypatch.setenv("SERVICE_API_KEY", "test-service-key")
|
||||||
|
monkeypatch.setenv("PDF_MAX_SIZE_BYTES", "1024")
|
||||||
|
|
||||||
|
from signing_service.application.settings.container import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from signing_service.main import create_app
|
||||||
|
|
||||||
|
return TestClient(create_app(), raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def form(pdf: bytes = b"%PDF-1.4 test") -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"files": {"file": ("document.pdf", pdf, "application/pdf")},
|
||||||
|
"data": {
|
||||||
|
"certificate_secret_name": "legacy-cert",
|
||||||
|
"certificate_password_secret_name": "legacy-password",
|
||||||
|
"company_slug": "legacy-company",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client: TestClient) -> None:
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_is_public(client: TestClient) -> None:
|
||||||
|
assert client.get("/version").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_rejects_missing_authentication(client: TestClient) -> None:
|
||||||
|
response = client.post("/documents/sign", **form())
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_rejects_invalid_authentication(client: TestClient) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/documents/sign", headers={"Authorization": "Bearer invalid"}, **form()
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "INVALID_CREDENTIALS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_with_valid_authentication_reaches_signing_flow(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/documents/sign",
|
||||||
|
headers={"Authorization": "Bearer test-service-key"},
|
||||||
|
**form(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "application/pdf"
|
||||||
|
assert response.content.endswith(b"-- Signed by fake signer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_pdf_is_mapped_without_internal_detail(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
from signing_service.api.routes import sign_document
|
||||||
|
from signing_service.infrastructure.pdf.pdf_validator_error import (
|
||||||
|
PDFValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
class InvalidPdfSigner:
|
||||||
|
async def sign(self, **kwargs: object) -> bytes:
|
||||||
|
raise PDFValidationError("PFX parsing detail")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sign_document, "create_pdf_signer", lambda: InvalidPdfSigner())
|
||||||
|
response = client.post(
|
||||||
|
"/documents/sign",
|
||||||
|
headers={"Authorization": "Bearer test-service-key"},
|
||||||
|
**form(b"not a PDF"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"error": {"code": "INVALID_PDF", "message": "Invalid PDF document"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_size_limit_is_controlled(client: TestClient) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/documents/sign",
|
||||||
|
headers={"Authorization": "Bearer test-service-key"},
|
||||||
|
**form(b"x" * 1025),
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
assert response.json()["error"]["code"] == "INVALID_REQUEST"
|
||||||
@ -1,59 +1,96 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from signing_service.application.use_cases.sign_document import (
|
from signing_service.application.errors import (
|
||||||
SignDocumentUseCase,
|
InvalidPdfError,
|
||||||
)
|
SecretProviderError,
|
||||||
from signing_service.application.use_cases.sign_document_dto import (
|
ServiceError,
|
||||||
SignDocumentCommand,
|
|
||||||
)
|
)
|
||||||
|
from signing_service.application.use_cases.sign_document import SignDocumentUseCase
|
||||||
|
from signing_service.application.use_cases.sign_document_dto import SignDocumentCommand
|
||||||
from signing_service.infrastructure.pdf.fake_pdf_signer import FakePDFSigner
|
from signing_service.infrastructure.pdf.fake_pdf_signer import FakePDFSigner
|
||||||
from signing_service.infrastructure.secrets.fake_secret_manager import (
|
from signing_service.infrastructure.pdf.pdf_validator_error import PDFValidationError
|
||||||
FakeSecretManager,
|
from signing_service.infrastructure.secrets.fake_secret_manager import FakeSecretManager
|
||||||
)
|
|
||||||
|
CERT_NAME = "configured-cert"
|
||||||
|
PASSWORD_NAME = "configured-password"
|
||||||
|
|
||||||
|
|
||||||
def test_sign_document_success():
|
def command() -> SignDocumentCommand:
|
||||||
# GIVEN: dependencias fake
|
return SignDocumentCommand(
|
||||||
fake_secrets = {
|
pdf_bytes=b"%PDF-1.4 fake pdf content",
|
||||||
"pdf-signing-cert": "FAKE_CERTIFICATE_DATA"
|
certificate_secret_name="legacy-cert-name",
|
||||||
}
|
certificate_password_secret_name="legacy-password-name",
|
||||||
secret_manager = FakeSecretManager(fake_secrets)
|
company_slug="legacy-company",
|
||||||
pdf_signer = FakePDFSigner()
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_document_success() -> None:
|
||||||
use_case = SignDocumentUseCase(
|
use_case = SignDocumentUseCase(
|
||||||
secret_manager=secret_manager,
|
secret_manager=FakeSecretManager(
|
||||||
pdf_signer=pdf_signer,
|
{CERT_NAME: "certificate", PASSWORD_NAME: "password"}
|
||||||
|
),
|
||||||
|
pdf_signer=FakePDFSigner(),
|
||||||
|
cert_secret_name=CERT_NAME,
|
||||||
|
cert_password_secret_name=PASSWORD_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
original_pdf = b"%PDF-1.4 fake pdf content"
|
result = asyncio.run(use_case.execute(command()))
|
||||||
|
|
||||||
command = SignDocumentCommand(
|
assert result.signed_pdf_bytes.startswith(command().pdf_bytes)
|
||||||
pdf_bytes=original_pdf,
|
assert result.signed_pdf_bytes.endswith(b"-- Signed by fake signer")
|
||||||
certificate_secret_name="pdf-signing-cert",
|
assert b"certificate" not in result.signed_pdf_bytes
|
||||||
certificate_password_secret_name="pdf-signing-cert-password",
|
assert b"password" not in result.signed_pdf_bytes
|
||||||
)
|
|
||||||
|
|
||||||
# WHEN: ejecutamos el caso de uso
|
|
||||||
result = use_case.execute(command)
|
|
||||||
|
|
||||||
# THEN: el PDF está "firmado"
|
|
||||||
assert result.signed_pdf_bytes.startswith(original_pdf)
|
|
||||||
assert b"FAKE_CERTIFICATE_DATA" in result.signed_pdf_bytes
|
|
||||||
|
|
||||||
|
|
||||||
def test_sign_document_secret_not_found():
|
def test_sign_document_maps_secret_manager_errors() -> None:
|
||||||
secret_manager = FakeSecretManager(secrets={})
|
|
||||||
pdf_signer = FakePDFSigner()
|
|
||||||
|
|
||||||
use_case = SignDocumentUseCase(
|
use_case = SignDocumentUseCase(
|
||||||
secret_manager=secret_manager,
|
secret_manager=FakeSecretManager({}),
|
||||||
pdf_signer=pdf_signer,
|
pdf_signer=FakePDFSigner(),
|
||||||
|
cert_secret_name=CERT_NAME,
|
||||||
|
cert_password_secret_name=PASSWORD_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
command = SignDocumentCommand(
|
with pytest.raises(SecretProviderError):
|
||||||
pdf_bytes=b"fake pdf",
|
asyncio.run(use_case.execute(command()))
|
||||||
certificate_secret_name="missing-secret",
|
|
||||||
|
|
||||||
|
class FailingSigner:
|
||||||
|
async def sign(self, **kwargs: object) -> bytes:
|
||||||
|
raise RuntimeError("PFX password was rejected")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPdfSigner:
|
||||||
|
async def sign(self, **kwargs: object) -> bytes:
|
||||||
|
raise PDFValidationError("internal parsing detail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_document_maps_signer_errors() -> None:
|
||||||
|
use_case = SignDocumentUseCase(
|
||||||
|
secret_manager=FakeSecretManager(
|
||||||
|
{CERT_NAME: "certificate", PASSWORD_NAME: "password"}
|
||||||
|
),
|
||||||
|
pdf_signer=FailingSigner(), # type: ignore[arg-type]
|
||||||
|
cert_secret_name=CERT_NAME,
|
||||||
|
cert_password_secret_name=PASSWORD_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ServiceError) as caught:
|
||||||
use_case.execute(command)
|
asyncio.run(use_case.execute(command()))
|
||||||
|
|
||||||
|
assert caught.value.code == "SIGNING_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_document_maps_invalid_pdf_errors() -> None:
|
||||||
|
use_case = SignDocumentUseCase(
|
||||||
|
secret_manager=FakeSecretManager(
|
||||||
|
{CERT_NAME: "certificate", PASSWORD_NAME: "password"}
|
||||||
|
),
|
||||||
|
pdf_signer=InvalidPdfSigner(), # type: ignore[arg-type]
|
||||||
|
cert_secret_name=CERT_NAME,
|
||||||
|
cert_password_secret_name=PASSWORD_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPdfError):
|
||||||
|
asyncio.run(use_case.execute(command()))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user