From 54126a2dd14e8c9d2a577fb27759c62872e98a56 Mon Sep 17 00:00:00 2001 From: herrdelta83 Date: Fri, 10 Apr 2026 15:11:52 -0600 Subject: [PATCH 1/5] file created security_event, sesion, transaccion, risk_feature, risk_assessment, fraud_action, audit_log, otp_challenge, token_blacklist, ip_reputation --- backend/app/entities/audit_log.py | 19 +++++++++++++++++++ backend/app/entities/fraud_action.py | 17 +++++++++++++++++ backend/app/entities/ip_reputation.py | 21 +++++++++++++++++++++ backend/app/entities/otp_challenge.py | 23 +++++++++++++++++++++++ backend/app/entities/risk_assessment.py | 20 ++++++++++++++++++++ backend/app/entities/risk_feature.py | 24 ++++++++++++++++++++++++ backend/app/entities/security_event.py | 17 +++++++++++++++++ backend/app/entities/sesion.py | 20 ++++++++++++++++++++ backend/app/entities/token_blacklist.py | 18 ++++++++++++++++++ backend/app/entities/transaccion.py | 23 +++++++++++++++++++++++ 10 files changed, 202 insertions(+) create mode 100644 backend/app/entities/audit_log.py create mode 100644 backend/app/entities/fraud_action.py create mode 100644 backend/app/entities/ip_reputation.py create mode 100644 backend/app/entities/otp_challenge.py create mode 100644 backend/app/entities/risk_assessment.py create mode 100644 backend/app/entities/risk_feature.py create mode 100644 backend/app/entities/security_event.py create mode 100644 backend/app/entities/sesion.py create mode 100644 backend/app/entities/token_blacklist.py create mode 100644 backend/app/entities/transaccion.py diff --git a/backend/app/entities/audit_log.py b/backend/app/entities/audit_log.py new file mode 100644 index 0000000..15b07e4 --- /dev/null +++ b/backend/app/entities/audit_log.py @@ -0,0 +1,19 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, JSON, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("usuario.id", ondelete="SET NULL"), nullable=True) + transaction_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("transaccion.id", ondelete="SET NULL"), nullable=True) + action: Mapped[str] = mapped_column(String, nullable=False) + resource: Mapped[str] = mapped_column(String, nullable=False) + details: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/fraud_action.py b/backend/app/entities/fraud_action.py new file mode 100644 index 0000000..1fbf5e2 --- /dev/null +++ b/backend/app/entities/fraud_action.py @@ -0,0 +1,17 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class FraudAction(Base): + __tablename__ = "fraud_action" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + transaction_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("transaccion.id", ondelete="CASCADE"), nullable=False) + action_type: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, default="pending") + executed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/ip_reputation.py b/backend/app/entities/ip_reputation.py new file mode 100644 index 0000000..0da2d27 --- /dev/null +++ b/backend/app/entities/ip_reputation.py @@ -0,0 +1,21 @@ +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import DateTime, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class IpReputation(Base): + __tablename__ = "ip_reputation" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + ip: Mapped[str] = mapped_column(String, unique=True, nullable=False) + risk_score: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + status: Mapped[str] = mapped_column(String, default="unknown") + failed_attempts: Mapped[int] = mapped_column(Integer, default=0) + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/otp_challenge.py b/backend/app/entities/otp_challenge.py new file mode 100644 index 0000000..c501060 --- /dev/null +++ b/backend/app/entities/otp_challenge.py @@ -0,0 +1,23 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class OtpChallenge(Base): + __tablename__ = "otp_challenge" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + transaction_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("transaccion.id", ondelete="SET NULL"), nullable=True) + code_hash: Mapped[str] = mapped_column(String, nullable=False) + channel: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, default="pending") + attempts: Mapped[int] = mapped_column(Integer, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, default=3) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/entities/risk_assessment.py b/backend/app/entities/risk_assessment.py new file mode 100644 index 0000000..0811df2 --- /dev/null +++ b/backend/app/entities/risk_assessment.py @@ -0,0 +1,20 @@ +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, JSON, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class RiskAssessment(Base): + __tablename__ = "risk_assessment" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + transaction_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("transaccion.id", ondelete="CASCADE"), nullable=False) + risk_score: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + risk_level: Mapped[str | None] = mapped_column(String, nullable=True) + decision: Mapped[str | None] = mapped_column(String, nullable=True) + reason: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/risk_feature.py b/backend/app/entities/risk_feature.py new file mode 100644 index 0000000..425a123 --- /dev/null +++ b/backend/app/entities/risk_feature.py @@ -0,0 +1,24 @@ +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class RiskFeature(Base): + __tablename__ = "risk_feature" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + transaction_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("transaccion.id", ondelete="CASCADE"), nullable=False) + velocity_1m: Mapped[int | None] = mapped_column(Integer, nullable=True) + velocity_1h: Mapped[int | None] = mapped_column(Integer, nullable=True) + amount_zscore: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + device_trust_score: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + geo_distance_km: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=2), nullable=True) + new_beneficiary: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + ip_risk_score: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + behavioral_score: Mapped[Decimal | None] = mapped_column(Numeric(precision=10, scale=4), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/security_event.py b/backend/app/entities/security_event.py new file mode 100644 index 0000000..d1ac531 --- /dev/null +++ b/backend/app/entities/security_event.py @@ -0,0 +1,17 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, JSON, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class SecurityEvent(Base): + __tablename__ = "security_event" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + type: Mapped[str] = mapped_column(String, nullable=False) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/sesion.py b/backend/app/entities/sesion.py new file mode 100644 index 0000000..4ca67ef --- /dev/null +++ b/backend/app/entities/sesion.py @@ -0,0 +1,20 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class Sesion(Base): + __tablename__ = "sesion" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + device_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("dispositivo.id", ondelete="SET NULL"), nullable=True) + ip: Mapped[str] = mapped_column(String, nullable=False) + country: Mapped[str | None] = mapped_column(String, nullable=True) + city: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/entities/token_blacklist.py b/backend/app/entities/token_blacklist.py new file mode 100644 index 0000000..445d789 --- /dev/null +++ b/backend/app/entities/token_blacklist.py @@ -0,0 +1,18 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class TokenBlacklist(Base): + __tablename__ = "token_blacklist" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("usuario.id", ondelete="SET NULL"), nullable=True) + token_jti: Mapped[str] = mapped_column(String, unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + reason: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/entities/transaccion.py b/backend/app/entities/transaccion.py new file mode 100644 index 0000000..119fad1 --- /dev/null +++ b/backend/app/entities/transaccion.py @@ -0,0 +1,23 @@ +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class Transaccion(Base): + __tablename__ = "transaccion" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + from_account_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("cuenta.id", ondelete="SET NULL"), nullable=True) + to_account: Mapped[str] = mapped_column(String, nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(precision=18, scale=2), nullable=False) + currency: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, default="pending") + ip: Mapped[str | None] = mapped_column(String, nullable=True) + device_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("dispositivo.id", ondelete="SET NULL"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) From 9159038940bb1d0fb66882b007ee683c67332442 Mon Sep 17 00:00:00 2001 From: herrdelta83 Date: Fri, 10 Apr 2026 15:12:52 -0600 Subject: [PATCH 2/5] 10 models for audit, fraud, ipreputation, otp, risk assesment, risk feature, security event, sesion, token blacklist, transaccion --- backend/app/models/audit_log.py | 24 +++++++++++++++ backend/app/models/fraud_action.py | 25 +++++++++++++++ backend/app/models/ip_reputation.py | 32 +++++++++++++++++++ backend/app/models/otp_challenge.py | 36 ++++++++++++++++++++++ backend/app/models/risk_assessment.py | 32 +++++++++++++++++++ backend/app/models/risk_feature.py | 44 +++++++++++++++++++++++++++ backend/app/models/security_event.py | 25 +++++++++++++++ backend/app/models/sesion.py | 31 +++++++++++++++++++ backend/app/models/token_blacklist.py | 22 ++++++++++++++ backend/app/models/transaccion.py | 36 ++++++++++++++++++++++ 10 files changed, 307 insertions(+) create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/fraud_action.py create mode 100644 backend/app/models/ip_reputation.py create mode 100644 backend/app/models/otp_challenge.py create mode 100644 backend/app/models/risk_assessment.py create mode 100644 backend/app/models/risk_feature.py create mode 100644 backend/app/models/security_event.py create mode 100644 backend/app/models/sesion.py create mode 100644 backend/app/models/token_blacklist.py create mode 100644 backend/app/models/transaccion.py diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..9a3feb3 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateAuditLogRequest(BaseModel): + user_id: uuid.UUID | None = None + transaction_id: uuid.UUID | None = None + action: str + resource: str + details: dict | None = None + + +class AuditLogResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID | None + transaction_id: uuid.UUID | None + action: str + resource: str + details: dict | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/fraud_action.py b/backend/app/models/fraud_action.py new file mode 100644 index 0000000..e3ab096 --- /dev/null +++ b/backend/app/models/fraud_action.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateFraudActionRequest(BaseModel): + transaction_id: uuid.UUID + action_type: str + status: str = "pending" + + +class UpdateFraudActionRequest(BaseModel): + action_type: str | None = None + status: str | None = None + + +class FraudActionResponse(BaseModel): + id: uuid.UUID + transaction_id: uuid.UUID + action_type: str + status: str + executed_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/ip_reputation.py b/backend/app/models/ip_reputation.py new file mode 100644 index 0000000..f9a18b4 --- /dev/null +++ b/backend/app/models/ip_reputation.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel + + +class CreateIpReputationRequest(BaseModel): + ip: str + risk_score: Decimal | None = None + status: str = "unknown" + failed_attempts: int = 0 + + +class UpdateIpReputationRequest(BaseModel): + risk_score: Decimal | None = None + status: str | None = None + failed_attempts: int | None = None + last_seen: datetime | None = None + + +class IpReputationResponse(BaseModel): + id: uuid.UUID + ip: str + risk_score: Decimal | None + status: str + failed_attempts: int + last_seen: datetime | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/otp_challenge.py b/backend/app/models/otp_challenge.py new file mode 100644 index 0000000..7229312 --- /dev/null +++ b/backend/app/models/otp_challenge.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateOtpChallengeRequest(BaseModel): + user_id: uuid.UUID + transaction_id: uuid.UUID | None = None + code_hash: str + channel: str + status: str = "pending" + max_attempts: int = 3 + expires_at: datetime + + +class UpdateOtpChallengeRequest(BaseModel): + status: str | None = None + attempts: int | None = None + verified_at: datetime | None = None + + +class OtpChallengeResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + transaction_id: uuid.UUID | None + code_hash: str + channel: str + status: str + attempts: int + max_attempts: int + expires_at: datetime + created_at: datetime + verified_at: datetime | None + + model_config = {"from_attributes": True} diff --git a/backend/app/models/risk_assessment.py b/backend/app/models/risk_assessment.py new file mode 100644 index 0000000..6cbf518 --- /dev/null +++ b/backend/app/models/risk_assessment.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel + + +class CreateRiskAssessmentRequest(BaseModel): + transaction_id: uuid.UUID + risk_score: Decimal | None = None + risk_level: str | None = None + decision: str | None = None + reason: dict | None = None + + +class UpdateRiskAssessmentRequest(BaseModel): + risk_score: Decimal | None = None + risk_level: str | None = None + decision: str | None = None + reason: dict | None = None + + +class RiskAssessmentResponse(BaseModel): + id: uuid.UUID + transaction_id: uuid.UUID + risk_score: Decimal | None + risk_level: str | None + decision: str | None + reason: dict | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/risk_feature.py b/backend/app/models/risk_feature.py new file mode 100644 index 0000000..de3ca42 --- /dev/null +++ b/backend/app/models/risk_feature.py @@ -0,0 +1,44 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel + + +class CreateRiskFeatureRequest(BaseModel): + transaction_id: uuid.UUID + velocity_1m: int | None = None + velocity_1h: int | None = None + amount_zscore: Decimal | None = None + device_trust_score: Decimal | None = None + geo_distance_km: Decimal | None = None + new_beneficiary: bool | None = None + ip_risk_score: Decimal | None = None + behavioral_score: Decimal | None = None + + +class UpdateRiskFeatureRequest(BaseModel): + velocity_1m: int | None = None + velocity_1h: int | None = None + amount_zscore: Decimal | None = None + device_trust_score: Decimal | None = None + geo_distance_km: Decimal | None = None + new_beneficiary: bool | None = None + ip_risk_score: Decimal | None = None + behavioral_score: Decimal | None = None + + +class RiskFeatureResponse(BaseModel): + id: uuid.UUID + transaction_id: uuid.UUID + velocity_1m: int | None + velocity_1h: int | None + amount_zscore: Decimal | None + device_trust_score: Decimal | None + geo_distance_km: Decimal | None + new_beneficiary: bool | None + ip_risk_score: Decimal | None + behavioral_score: Decimal | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/security_event.py b/backend/app/models/security_event.py new file mode 100644 index 0000000..ba69d6b --- /dev/null +++ b/backend/app/models/security_event.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateSecurityEventRequest(BaseModel): + user_id: uuid.UUID + type: str + metadata_: dict | None = None + + +class UpdateSecurityEventRequest(BaseModel): + type: str | None = None + metadata_: dict | None = None + + +class SecurityEventResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + type: str + metadata_: dict | None + created_at: datetime + + model_config = {"from_attributes": True, "populate_by_name": True} diff --git a/backend/app/models/sesion.py b/backend/app/models/sesion.py new file mode 100644 index 0000000..11efef7 --- /dev/null +++ b/backend/app/models/sesion.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateSesionRequest(BaseModel): + user_id: uuid.UUID + device_id: uuid.UUID | None = None + ip: str + country: str | None = None + city: str | None = None + + +class UpdateSesionRequest(BaseModel): + country: str | None = None + city: str | None = None + ended_at: datetime | None = None + + +class SesionResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + device_id: uuid.UUID | None + ip: str + country: str | None + city: str | None + created_at: datetime + ended_at: datetime | None + + model_config = {"from_attributes": True} diff --git a/backend/app/models/token_blacklist.py b/backend/app/models/token_blacklist.py new file mode 100644 index 0000000..53797d7 --- /dev/null +++ b/backend/app/models/token_blacklist.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateTokenBlacklistRequest(BaseModel): + user_id: uuid.UUID | None = None + token_jti: str + expires_at: datetime + reason: str | None = None + + +class TokenBlacklistResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID | None + token_jti: str + expires_at: datetime + reason: str | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/models/transaccion.py b/backend/app/models/transaccion.py new file mode 100644 index 0000000..bcf5e31 --- /dev/null +++ b/backend/app/models/transaccion.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class CreateTransaccionRequest(BaseModel): + user_id: uuid.UUID + from_account_id: uuid.UUID | None = None + to_account: str + amount: Decimal = Field(gt=0) + currency: str + status: str = "pending" + ip: str | None = None + device_id: uuid.UUID | None = None + + +class UpdateTransaccionRequest(BaseModel): + status: str | None = None + ip: str | None = None + + +class TransaccionResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + from_account_id: uuid.UUID | None + to_account: str + amount: Decimal + currency: str + status: str + ip: str | None + device_id: uuid.UUID | None + created_at: datetime + + model_config = {"from_attributes": True} From d904dda4f9d5640b129610c234bd6bbda59e0e8c Mon Sep 17 00:00:00 2001 From: herrdelta83 Date: Fri, 10 Apr 2026 15:13:30 -0600 Subject: [PATCH 3/5] 10 repositories for audit, fraud, ipreputation, otp, risk assesment, risk feature, security event, sesion, token blacklist, transaccion --- .../app/repositories/audit_log_repository.py | 42 +++++++++++++ .../repositories/fraud_action_repository.py | 42 +++++++++++++ .../repositories/ip_reputation_repository.py | 44 +++++++++++++ .../repositories/otp_challenge_repository.py | 59 ++++++++++++++++++ .../risk_assessment_repository.py | 44 +++++++++++++ .../repositories/risk_feature_repository.py | 42 +++++++++++++ .../repositories/security_event_repository.py | 41 +++++++++++++ backend/app/repositories/sesion_repository.py | 50 +++++++++++++++ .../token_blacklist_repository.py | 40 ++++++++++++ .../repositories/transaccion_repository.py | 61 +++++++++++++++++++ 10 files changed, 465 insertions(+) create mode 100644 backend/app/repositories/audit_log_repository.py create mode 100644 backend/app/repositories/fraud_action_repository.py create mode 100644 backend/app/repositories/ip_reputation_repository.py create mode 100644 backend/app/repositories/otp_challenge_repository.py create mode 100644 backend/app/repositories/risk_assessment_repository.py create mode 100644 backend/app/repositories/risk_feature_repository.py create mode 100644 backend/app/repositories/security_event_repository.py create mode 100644 backend/app/repositories/sesion_repository.py create mode 100644 backend/app/repositories/token_blacklist_repository.py create mode 100644 backend/app/repositories/transaccion_repository.py diff --git a/backend/app/repositories/audit_log_repository.py b/backend/app/repositories/audit_log_repository.py new file mode 100644 index 0000000..e5b0550 --- /dev/null +++ b/backend/app/repositories/audit_log_repository.py @@ -0,0 +1,42 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.audit_log import AuditLog + + +class AuditLogRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create( + self, + action: str, + resource: str, + user_id: uuid.UUID | None, + transaction_id: uuid.UUID | None, + details: dict | None, + ) -> AuditLog: + log = AuditLog( + user_id=user_id, transaction_id=transaction_id, action=action, resource=resource, details=details + ) + self.db.add(log) + await self.db.commit() + await self.db.refresh(log) + return log + + async def get_by_id(self, log_id: uuid.UUID) -> AuditLog | None: + result = await self.db.execute(select(AuditLog).where(AuditLog.id == log_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[AuditLog]: + query = select(AuditLog) + if user_id is not None: + query = query.where(AuditLog.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def delete(self, log: AuditLog) -> None: + await self.db.delete(log) + await self.db.commit() diff --git a/backend/app/repositories/fraud_action_repository.py b/backend/app/repositories/fraud_action_repository.py new file mode 100644 index 0000000..bede70c --- /dev/null +++ b/backend/app/repositories/fraud_action_repository.py @@ -0,0 +1,42 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.fraud_action import FraudAction + + +class FraudActionRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, transaction_id: uuid.UUID, action_type: str, status: str) -> FraudAction: + action = FraudAction(transaction_id=transaction_id, action_type=action_type, status=status) + self.db.add(action) + await self.db.commit() + await self.db.refresh(action) + return action + + async def get_by_id(self, action_id: uuid.UUID) -> FraudAction | None: + result = await self.db.execute(select(FraudAction).where(FraudAction.id == action_id)) + return result.scalar_one_or_none() + + async def get_by_transaction(self, transaction_id: uuid.UUID) -> list[FraudAction]: + result = await self.db.execute(select(FraudAction).where(FraudAction.transaction_id == transaction_id)) + return list(result.scalars().all()) + + async def get_all(self) -> list[FraudAction]: + result = await self.db.execute(select(FraudAction)) + return list(result.scalars().all()) + + async def update(self, action: FraudAction, **fields: object) -> FraudAction: + for key, value in fields.items(): + if value is not None: + setattr(action, key, value) + await self.db.commit() + await self.db.refresh(action) + return action + + async def delete(self, action: FraudAction) -> None: + await self.db.delete(action) + await self.db.commit() diff --git a/backend/app/repositories/ip_reputation_repository.py b/backend/app/repositories/ip_reputation_repository.py new file mode 100644 index 0000000..c2397b1 --- /dev/null +++ b/backend/app/repositories/ip_reputation_repository.py @@ -0,0 +1,44 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.ip_reputation import IpReputation + + +class IpReputationRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, ip: str, status: str, failed_attempts: int, risk_score: object) -> IpReputation: + entry = IpReputation(ip=ip, status=status, failed_attempts=failed_attempts, risk_score=risk_score) + self.db.add(entry) + await self.db.commit() + await self.db.refresh(entry) + return entry + + async def get_by_id(self, entry_id: uuid.UUID) -> IpReputation | None: + result = await self.db.execute(select(IpReputation).where(IpReputation.id == entry_id)) + return result.scalar_one_or_none() + + async def get_by_ip(self, ip: str) -> IpReputation | None: + result = await self.db.execute(select(IpReputation).where(IpReputation.ip == ip)) + return result.scalar_one_or_none() + + async def get_all(self) -> list[IpReputation]: + result = await self.db.execute(select(IpReputation)) + return list(result.scalars().all()) + + async def update(self, entry: IpReputation, **fields: object) -> IpReputation: + for key, value in fields.items(): + if value is not None: + setattr(entry, key, value) + entry.updated_at = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(entry) + return entry + + async def delete(self, entry: IpReputation) -> None: + await self.db.delete(entry) + await self.db.commit() diff --git a/backend/app/repositories/otp_challenge_repository.py b/backend/app/repositories/otp_challenge_repository.py new file mode 100644 index 0000000..dc629ba --- /dev/null +++ b/backend/app/repositories/otp_challenge_repository.py @@ -0,0 +1,59 @@ +import uuid +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.otp_challenge import OtpChallenge + + +class OtpChallengeRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create( + self, + user_id: uuid.UUID, + code_hash: str, + channel: str, + expires_at: datetime, + transaction_id: uuid.UUID | None, + status: str, + max_attempts: int, + ) -> OtpChallenge: + challenge = OtpChallenge( + user_id=user_id, + transaction_id=transaction_id, + code_hash=code_hash, + channel=channel, + status=status, + max_attempts=max_attempts, + expires_at=expires_at, + ) + self.db.add(challenge) + await self.db.commit() + await self.db.refresh(challenge) + return challenge + + async def get_by_id(self, challenge_id: uuid.UUID) -> OtpChallenge | None: + result = await self.db.execute(select(OtpChallenge).where(OtpChallenge.id == challenge_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[OtpChallenge]: + query = select(OtpChallenge) + if user_id is not None: + query = query.where(OtpChallenge.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, challenge: OtpChallenge, **fields: object) -> OtpChallenge: + for key, value in fields.items(): + if value is not None: + setattr(challenge, key, value) + await self.db.commit() + await self.db.refresh(challenge) + return challenge + + async def delete(self, challenge: OtpChallenge) -> None: + await self.db.delete(challenge) + await self.db.commit() diff --git a/backend/app/repositories/risk_assessment_repository.py b/backend/app/repositories/risk_assessment_repository.py new file mode 100644 index 0000000..8625031 --- /dev/null +++ b/backend/app/repositories/risk_assessment_repository.py @@ -0,0 +1,44 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.risk_assessment import RiskAssessment + + +class RiskAssessmentRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, transaction_id: uuid.UUID, **fields: object) -> RiskAssessment: + assessment = RiskAssessment(transaction_id=transaction_id, **fields) + self.db.add(assessment) + await self.db.commit() + await self.db.refresh(assessment) + return assessment + + async def get_by_id(self, assessment_id: uuid.UUID) -> RiskAssessment | None: + result = await self.db.execute(select(RiskAssessment).where(RiskAssessment.id == assessment_id)) + return result.scalar_one_or_none() + + async def get_by_transaction(self, transaction_id: uuid.UUID) -> list[RiskAssessment]: + result = await self.db.execute( + select(RiskAssessment).where(RiskAssessment.transaction_id == transaction_id) + ) + return list(result.scalars().all()) + + async def get_all(self) -> list[RiskAssessment]: + result = await self.db.execute(select(RiskAssessment)) + return list(result.scalars().all()) + + async def update(self, assessment: RiskAssessment, **fields: object) -> RiskAssessment: + for key, value in fields.items(): + if value is not None: + setattr(assessment, key, value) + await self.db.commit() + await self.db.refresh(assessment) + return assessment + + async def delete(self, assessment: RiskAssessment) -> None: + await self.db.delete(assessment) + await self.db.commit() diff --git a/backend/app/repositories/risk_feature_repository.py b/backend/app/repositories/risk_feature_repository.py new file mode 100644 index 0000000..81e0517 --- /dev/null +++ b/backend/app/repositories/risk_feature_repository.py @@ -0,0 +1,42 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.risk_feature import RiskFeature + + +class RiskFeatureRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, transaction_id: uuid.UUID, **fields: object) -> RiskFeature: + feature = RiskFeature(transaction_id=transaction_id, **fields) + self.db.add(feature) + await self.db.commit() + await self.db.refresh(feature) + return feature + + async def get_by_id(self, feature_id: uuid.UUID) -> RiskFeature | None: + result = await self.db.execute(select(RiskFeature).where(RiskFeature.id == feature_id)) + return result.scalar_one_or_none() + + async def get_by_transaction(self, transaction_id: uuid.UUID) -> list[RiskFeature]: + result = await self.db.execute(select(RiskFeature).where(RiskFeature.transaction_id == transaction_id)) + return list(result.scalars().all()) + + async def get_all(self) -> list[RiskFeature]: + result = await self.db.execute(select(RiskFeature)) + return list(result.scalars().all()) + + async def update(self, feature: RiskFeature, **fields: object) -> RiskFeature: + for key, value in fields.items(): + if value is not None: + setattr(feature, key, value) + await self.db.commit() + await self.db.refresh(feature) + return feature + + async def delete(self, feature: RiskFeature) -> None: + await self.db.delete(feature) + await self.db.commit() diff --git a/backend/app/repositories/security_event_repository.py b/backend/app/repositories/security_event_repository.py new file mode 100644 index 0000000..df6c5b5 --- /dev/null +++ b/backend/app/repositories/security_event_repository.py @@ -0,0 +1,41 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.security_event import SecurityEvent + + +class SecurityEventRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, type: str, metadata_: dict | None) -> SecurityEvent: + event = SecurityEvent(user_id=user_id, type=type, metadata_=metadata_) + self.db.add(event) + await self.db.commit() + await self.db.refresh(event) + return event + + async def get_by_id(self, event_id: uuid.UUID) -> SecurityEvent | None: + result = await self.db.execute(select(SecurityEvent).where(SecurityEvent.id == event_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[SecurityEvent]: + query = select(SecurityEvent) + if user_id is not None: + query = query.where(SecurityEvent.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, event: SecurityEvent, **fields: object) -> SecurityEvent: + for key, value in fields.items(): + if value is not None: + setattr(event, key, value) + await self.db.commit() + await self.db.refresh(event) + return event + + async def delete(self, event: SecurityEvent) -> None: + await self.db.delete(event) + await self.db.commit() diff --git a/backend/app/repositories/sesion_repository.py b/backend/app/repositories/sesion_repository.py new file mode 100644 index 0000000..8be4d2e --- /dev/null +++ b/backend/app/repositories/sesion_repository.py @@ -0,0 +1,50 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.sesion import Sesion + + +class SesionRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create( + self, user_id: uuid.UUID, ip: str, device_id: uuid.UUID | None, country: str | None, city: str | None + ) -> Sesion: + sesion = Sesion(user_id=user_id, ip=ip, device_id=device_id, country=country, city=city) + self.db.add(sesion) + await self.db.commit() + await self.db.refresh(sesion) + return sesion + + async def get_by_id(self, sesion_id: uuid.UUID) -> Sesion | None: + result = await self.db.execute(select(Sesion).where(Sesion.id == sesion_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[Sesion]: + query = select(Sesion) + if user_id is not None: + query = query.where(Sesion.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, sesion: Sesion, **fields: object) -> Sesion: + for key, value in fields.items(): + if value is not None: + setattr(sesion, key, value) + await self.db.commit() + await self.db.refresh(sesion) + return sesion + + async def end(self, sesion: Sesion) -> Sesion: + sesion.ended_at = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(sesion) + return sesion + + async def delete(self, sesion: Sesion) -> None: + await self.db.delete(sesion) + await self.db.commit() diff --git a/backend/app/repositories/token_blacklist_repository.py b/backend/app/repositories/token_blacklist_repository.py new file mode 100644 index 0000000..cbd2c00 --- /dev/null +++ b/backend/app/repositories/token_blacklist_repository.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.token_blacklist import TokenBlacklist + + +class TokenBlacklistRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create( + self, token_jti: str, expires_at: datetime, user_id: uuid.UUID | None, reason: str | None + ) -> TokenBlacklist: + entry = TokenBlacklist(user_id=user_id, token_jti=token_jti, expires_at=expires_at, reason=reason) + self.db.add(entry) + await self.db.commit() + await self.db.refresh(entry) + return entry + + async def get_by_id(self, entry_id: uuid.UUID) -> TokenBlacklist | None: + result = await self.db.execute(select(TokenBlacklist).where(TokenBlacklist.id == entry_id)) + return result.scalar_one_or_none() + + async def get_by_jti(self, token_jti: str) -> TokenBlacklist | None: + result = await self.db.execute(select(TokenBlacklist).where(TokenBlacklist.token_jti == token_jti)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[TokenBlacklist]: + query = select(TokenBlacklist) + if user_id is not None: + query = query.where(TokenBlacklist.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def delete(self, entry: TokenBlacklist) -> None: + await self.db.delete(entry) + await self.db.commit() diff --git a/backend/app/repositories/transaccion_repository.py b/backend/app/repositories/transaccion_repository.py new file mode 100644 index 0000000..b9de654 --- /dev/null +++ b/backend/app/repositories/transaccion_repository.py @@ -0,0 +1,61 @@ +import uuid +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.transaccion import Transaccion + + +class TransaccionRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create( + self, + user_id: uuid.UUID, + to_account: str, + amount: Decimal, + currency: str, + status: str, + from_account_id: uuid.UUID | None, + ip: str | None, + device_id: uuid.UUID | None, + ) -> Transaccion: + transaccion = Transaccion( + user_id=user_id, + from_account_id=from_account_id, + to_account=to_account, + amount=amount, + currency=currency, + status=status, + ip=ip, + device_id=device_id, + ) + self.db.add(transaccion) + await self.db.commit() + await self.db.refresh(transaccion) + return transaccion + + async def get_by_id(self, transaccion_id: uuid.UUID) -> Transaccion | None: + result = await self.db.execute(select(Transaccion).where(Transaccion.id == transaccion_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[Transaccion]: + query = select(Transaccion) + if user_id is not None: + query = query.where(Transaccion.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, transaccion: Transaccion, **fields: object) -> Transaccion: + for key, value in fields.items(): + if value is not None: + setattr(transaccion, key, value) + await self.db.commit() + await self.db.refresh(transaccion) + return transaccion + + async def delete(self, transaccion: Transaccion) -> None: + await self.db.delete(transaccion) + await self.db.commit() From 50e944c7d70743457acc3fb021ff7c756a14ea09 Mon Sep 17 00:00:00 2001 From: herrdelta83 Date: Fri, 10 Apr 2026 15:14:00 -0600 Subject: [PATCH 4/5] new routes for security_events, sesiones, transacciones, risk_features, risk_assessments, fraud_actions, audit_logs, otp_challenges, token_blacklist, ip_reputations --- backend/app/api/audit_logs.py | 49 ++++++++++++++++++ backend/app/api/fraud_actions.py | 60 ++++++++++++++++++++++ backend/app/api/ip_reputations.py | 74 +++++++++++++++++++++++++++ backend/app/api/otp_challenges.py | 64 +++++++++++++++++++++++ backend/app/api/risk_assessments.py | 72 ++++++++++++++++++++++++++ backend/app/api/risk_features.py | 78 +++++++++++++++++++++++++++++ backend/app/api/router.py | 20 ++++++++ backend/app/api/security_events.py | 56 +++++++++++++++++++++ backend/app/api/sesiones.py | 65 ++++++++++++++++++++++++ backend/app/api/token_blacklist.py | 56 +++++++++++++++++++++ backend/app/api/transacciones.py | 65 ++++++++++++++++++++++++ 11 files changed, 659 insertions(+) create mode 100644 backend/app/api/audit_logs.py create mode 100644 backend/app/api/fraud_actions.py create mode 100644 backend/app/api/ip_reputations.py create mode 100644 backend/app/api/otp_challenges.py create mode 100644 backend/app/api/risk_assessments.py create mode 100644 backend/app/api/risk_features.py create mode 100644 backend/app/api/security_events.py create mode 100644 backend/app/api/sesiones.py create mode 100644 backend/app/api/token_blacklist.py create mode 100644 backend/app/api/transacciones.py diff --git a/backend/app/api/audit_logs.py b/backend/app/api/audit_logs.py new file mode 100644 index 0000000..825a64d --- /dev/null +++ b/backend/app/api/audit_logs.py @@ -0,0 +1,49 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.audit_log import AuditLogResponse, CreateAuditLogRequest +from app.repositories.audit_log_repository import AuditLogRepository + +router = APIRouter(prefix="/audit-logs", tags=["audit-logs"]) + + +@router.post("", response_model=AuditLogResponse, status_code=status.HTTP_201_CREATED) +async def create_audit_log(body: CreateAuditLogRequest, db: AsyncSession = Depends(get_db)) -> AuditLogResponse: + repo = AuditLogRepository(db) + return await repo.create( + action=body.action, + resource=body.resource, + user_id=body.user_id, + transaction_id=body.transaction_id, + details=body.details, + ) + + +@router.get("", response_model=list[AuditLogResponse]) +async def list_audit_logs( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[AuditLogResponse]: + repo = AuditLogRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{log_id}", response_model=AuditLogResponse) +async def get_audit_log(log_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> AuditLogResponse: + repo = AuditLogRepository(db) + log = await repo.get_by_id(log_id) + if not log: + raise HTTPException(status_code=404, detail="Audit log not found") + return log + + +@router.delete("/{log_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_audit_log(log_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = AuditLogRepository(db) + log = await repo.get_by_id(log_id) + if not log: + raise HTTPException(status_code=404, detail="Audit log not found") + await repo.delete(log) diff --git a/backend/app/api/fraud_actions.py b/backend/app/api/fraud_actions.py new file mode 100644 index 0000000..a5f8f24 --- /dev/null +++ b/backend/app/api/fraud_actions.py @@ -0,0 +1,60 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.fraud_action import CreateFraudActionRequest, FraudActionResponse, UpdateFraudActionRequest +from app.repositories.fraud_action_repository import FraudActionRepository + +router = APIRouter(prefix="/fraud-actions", tags=["fraud-actions"]) + + +@router.post("", response_model=FraudActionResponse, status_code=status.HTTP_201_CREATED) +async def create_fraud_action( + body: CreateFraudActionRequest, db: AsyncSession = Depends(get_db) +) -> FraudActionResponse: + repo = FraudActionRepository(db) + return await repo.create( + transaction_id=body.transaction_id, action_type=body.action_type, status=body.status + ) + + +@router.get("", response_model=list[FraudActionResponse]) +async def list_fraud_actions( + transaction_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[FraudActionResponse]: + repo = FraudActionRepository(db) + if transaction_id: + return await repo.get_by_transaction(transaction_id) + return await repo.get_all() + + +@router.get("/{action_id}", response_model=FraudActionResponse) +async def get_fraud_action(action_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> FraudActionResponse: + repo = FraudActionRepository(db) + action = await repo.get_by_id(action_id) + if not action: + raise HTTPException(status_code=404, detail="Fraud action not found") + return action + + +@router.patch("/{action_id}", response_model=FraudActionResponse) +async def update_fraud_action( + action_id: uuid.UUID, body: UpdateFraudActionRequest, db: AsyncSession = Depends(get_db) +) -> FraudActionResponse: + repo = FraudActionRepository(db) + action = await repo.get_by_id(action_id) + if not action: + raise HTTPException(status_code=404, detail="Fraud action not found") + return await repo.update(action, action_type=body.action_type, status=body.status) + + +@router.delete("/{action_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_fraud_action(action_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = FraudActionRepository(db) + action = await repo.get_by_id(action_id) + if not action: + raise HTTPException(status_code=404, detail="Fraud action not found") + await repo.delete(action) diff --git a/backend/app/api/ip_reputations.py b/backend/app/api/ip_reputations.py new file mode 100644 index 0000000..4cf5cf2 --- /dev/null +++ b/backend/app/api/ip_reputations.py @@ -0,0 +1,74 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.ip_reputation import CreateIpReputationRequest, IpReputationResponse, UpdateIpReputationRequest +from app.repositories.ip_reputation_repository import IpReputationRepository + +router = APIRouter(prefix="/ip-reputations", tags=["ip-reputations"]) + + +@router.post("", response_model=IpReputationResponse, status_code=status.HTTP_201_CREATED) +async def create_ip_reputation( + body: CreateIpReputationRequest, db: AsyncSession = Depends(get_db) +) -> IpReputationResponse: + repo = IpReputationRepository(db) + try: + return await repo.create( + ip=body.ip, status=body.status, failed_attempts=body.failed_attempts, risk_score=body.risk_score + ) + except IntegrityError: + raise HTTPException(status_code=409, detail="IP already registered") + + +@router.get("", response_model=list[IpReputationResponse]) +async def list_ip_reputations(db: AsyncSession = Depends(get_db)) -> list[IpReputationResponse]: + repo = IpReputationRepository(db) + return await repo.get_all() + + +@router.get("/by-ip/{ip}", response_model=IpReputationResponse) +async def get_by_ip(ip: str, db: AsyncSession = Depends(get_db)) -> IpReputationResponse: + repo = IpReputationRepository(db) + entry = await repo.get_by_ip(ip) + if not entry: + raise HTTPException(status_code=404, detail="IP not found") + return entry + + +@router.get("/{entry_id}", response_model=IpReputationResponse) +async def get_ip_reputation(entry_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> IpReputationResponse: + repo = IpReputationRepository(db) + entry = await repo.get_by_id(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="IP not found") + return entry + + +@router.patch("/{entry_id}", response_model=IpReputationResponse) +async def update_ip_reputation( + entry_id: uuid.UUID, body: UpdateIpReputationRequest, db: AsyncSession = Depends(get_db) +) -> IpReputationResponse: + repo = IpReputationRepository(db) + entry = await repo.get_by_id(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="IP not found") + return await repo.update( + entry, + risk_score=body.risk_score, + status=body.status, + failed_attempts=body.failed_attempts, + last_seen=body.last_seen, + ) + + +@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ip_reputation(entry_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = IpReputationRepository(db) + entry = await repo.get_by_id(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="IP not found") + await repo.delete(entry) diff --git a/backend/app/api/otp_challenges.py b/backend/app/api/otp_challenges.py new file mode 100644 index 0000000..fd6addc --- /dev/null +++ b/backend/app/api/otp_challenges.py @@ -0,0 +1,64 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.otp_challenge import CreateOtpChallengeRequest, OtpChallengeResponse, UpdateOtpChallengeRequest +from app.repositories.otp_challenge_repository import OtpChallengeRepository + +router = APIRouter(prefix="/otp-challenges", tags=["otp-challenges"]) + + +@router.post("", response_model=OtpChallengeResponse, status_code=status.HTTP_201_CREATED) +async def create_otp_challenge( + body: CreateOtpChallengeRequest, db: AsyncSession = Depends(get_db) +) -> OtpChallengeResponse: + repo = OtpChallengeRepository(db) + return await repo.create( + user_id=body.user_id, + transaction_id=body.transaction_id, + code_hash=body.code_hash, + channel=body.channel, + status=body.status, + max_attempts=body.max_attempts, + expires_at=body.expires_at, + ) + + +@router.get("", response_model=list[OtpChallengeResponse]) +async def list_otp_challenges( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[OtpChallengeResponse]: + repo = OtpChallengeRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{challenge_id}", response_model=OtpChallengeResponse) +async def get_otp_challenge(challenge_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> OtpChallengeResponse: + repo = OtpChallengeRepository(db) + challenge = await repo.get_by_id(challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="OTP challenge not found") + return challenge + + +@router.patch("/{challenge_id}", response_model=OtpChallengeResponse) +async def update_otp_challenge( + challenge_id: uuid.UUID, body: UpdateOtpChallengeRequest, db: AsyncSession = Depends(get_db) +) -> OtpChallengeResponse: + repo = OtpChallengeRepository(db) + challenge = await repo.get_by_id(challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="OTP challenge not found") + return await repo.update(challenge, status=body.status, attempts=body.attempts, verified_at=body.verified_at) + + +@router.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_otp_challenge(challenge_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = OtpChallengeRepository(db) + challenge = await repo.get_by_id(challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="OTP challenge not found") + await repo.delete(challenge) diff --git a/backend/app/api/risk_assessments.py b/backend/app/api/risk_assessments.py new file mode 100644 index 0000000..b729691 --- /dev/null +++ b/backend/app/api/risk_assessments.py @@ -0,0 +1,72 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.risk_assessment import CreateRiskAssessmentRequest, RiskAssessmentResponse, UpdateRiskAssessmentRequest +from app.repositories.risk_assessment_repository import RiskAssessmentRepository + +router = APIRouter(prefix="/risk-assessments", tags=["risk-assessments"]) + + +@router.post("", response_model=RiskAssessmentResponse, status_code=status.HTTP_201_CREATED) +async def create_risk_assessment( + body: CreateRiskAssessmentRequest, db: AsyncSession = Depends(get_db) +) -> RiskAssessmentResponse: + repo = RiskAssessmentRepository(db) + return await repo.create( + transaction_id=body.transaction_id, + risk_score=body.risk_score, + risk_level=body.risk_level, + decision=body.decision, + reason=body.reason, + ) + + +@router.get("", response_model=list[RiskAssessmentResponse]) +async def list_risk_assessments( + transaction_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[RiskAssessmentResponse]: + repo = RiskAssessmentRepository(db) + if transaction_id: + return await repo.get_by_transaction(transaction_id) + return await repo.get_all() + + +@router.get("/{assessment_id}", response_model=RiskAssessmentResponse) +async def get_risk_assessment( + assessment_id: uuid.UUID, db: AsyncSession = Depends(get_db) +) -> RiskAssessmentResponse: + repo = RiskAssessmentRepository(db) + assessment = await repo.get_by_id(assessment_id) + if not assessment: + raise HTTPException(status_code=404, detail="Risk assessment not found") + return assessment + + +@router.patch("/{assessment_id}", response_model=RiskAssessmentResponse) +async def update_risk_assessment( + assessment_id: uuid.UUID, body: UpdateRiskAssessmentRequest, db: AsyncSession = Depends(get_db) +) -> RiskAssessmentResponse: + repo = RiskAssessmentRepository(db) + assessment = await repo.get_by_id(assessment_id) + if not assessment: + raise HTTPException(status_code=404, detail="Risk assessment not found") + return await repo.update( + assessment, + risk_score=body.risk_score, + risk_level=body.risk_level, + decision=body.decision, + reason=body.reason, + ) + + +@router.delete("/{assessment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_risk_assessment(assessment_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = RiskAssessmentRepository(db) + assessment = await repo.get_by_id(assessment_id) + if not assessment: + raise HTTPException(status_code=404, detail="Risk assessment not found") + await repo.delete(assessment) diff --git a/backend/app/api/risk_features.py b/backend/app/api/risk_features.py new file mode 100644 index 0000000..7c1f040 --- /dev/null +++ b/backend/app/api/risk_features.py @@ -0,0 +1,78 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.risk_feature import CreateRiskFeatureRequest, RiskFeatureResponse, UpdateRiskFeatureRequest +from app.repositories.risk_feature_repository import RiskFeatureRepository + +router = APIRouter(prefix="/risk-features", tags=["risk-features"]) + + +@router.post("", response_model=RiskFeatureResponse, status_code=status.HTTP_201_CREATED) +async def create_risk_feature( + body: CreateRiskFeatureRequest, db: AsyncSession = Depends(get_db) +) -> RiskFeatureResponse: + repo = RiskFeatureRepository(db) + return await repo.create( + transaction_id=body.transaction_id, + velocity_1m=body.velocity_1m, + velocity_1h=body.velocity_1h, + amount_zscore=body.amount_zscore, + device_trust_score=body.device_trust_score, + geo_distance_km=body.geo_distance_km, + new_beneficiary=body.new_beneficiary, + ip_risk_score=body.ip_risk_score, + behavioral_score=body.behavioral_score, + ) + + +@router.get("", response_model=list[RiskFeatureResponse]) +async def list_risk_features( + transaction_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[RiskFeatureResponse]: + repo = RiskFeatureRepository(db) + if transaction_id: + return await repo.get_by_transaction(transaction_id) + return await repo.get_all() + + +@router.get("/{feature_id}", response_model=RiskFeatureResponse) +async def get_risk_feature(feature_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> RiskFeatureResponse: + repo = RiskFeatureRepository(db) + feature = await repo.get_by_id(feature_id) + if not feature: + raise HTTPException(status_code=404, detail="Risk feature not found") + return feature + + +@router.patch("/{feature_id}", response_model=RiskFeatureResponse) +async def update_risk_feature( + feature_id: uuid.UUID, body: UpdateRiskFeatureRequest, db: AsyncSession = Depends(get_db) +) -> RiskFeatureResponse: + repo = RiskFeatureRepository(db) + feature = await repo.get_by_id(feature_id) + if not feature: + raise HTTPException(status_code=404, detail="Risk feature not found") + return await repo.update( + feature, + velocity_1m=body.velocity_1m, + velocity_1h=body.velocity_1h, + amount_zscore=body.amount_zscore, + device_trust_score=body.device_trust_score, + geo_distance_km=body.geo_distance_km, + new_beneficiary=body.new_beneficiary, + ip_risk_score=body.ip_risk_score, + behavioral_score=body.behavioral_score, + ) + + +@router.delete("/{feature_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_risk_feature(feature_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = RiskFeatureRepository(db) + feature = await repo.get_by_id(feature_id) + if not feature: + raise HTTPException(status_code=404, detail="Risk feature not found") + await repo.delete(feature) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index dcab2fc..3e284cd 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,9 +1,19 @@ from fastapi import APIRouter +from app.api.audit_logs import router as audit_logs_router from app.api.beneficiarios import router as beneficiarios_router from app.api.cuentas import router as cuentas_router from app.api.dispositivos import router as dispositivos_router +from app.api.fraud_actions import router as fraud_actions_router from app.api.health import router as health_router +from app.api.ip_reputations import router as ip_reputations_router +from app.api.otp_challenges import router as otp_challenges_router +from app.api.risk_assessments import router as risk_assessments_router +from app.api.risk_features import router as risk_features_router +from app.api.security_events import router as security_events_router +from app.api.sesiones import router as sesiones_router +from app.api.token_blacklist import router as token_blacklist_router +from app.api.transacciones import router as transacciones_router from app.api.usuarios import router as usuarios_router api_router = APIRouter(prefix="/api") @@ -12,3 +22,13 @@ api_router.include_router(cuentas_router) api_router.include_router(dispositivos_router) api_router.include_router(beneficiarios_router) +api_router.include_router(sesiones_router) +api_router.include_router(transacciones_router) +api_router.include_router(security_events_router) +api_router.include_router(risk_features_router) +api_router.include_router(risk_assessments_router) +api_router.include_router(fraud_actions_router) +api_router.include_router(audit_logs_router) +api_router.include_router(otp_challenges_router) +api_router.include_router(token_blacklist_router) +api_router.include_router(ip_reputations_router) diff --git a/backend/app/api/security_events.py b/backend/app/api/security_events.py new file mode 100644 index 0000000..f8c5370 --- /dev/null +++ b/backend/app/api/security_events.py @@ -0,0 +1,56 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.security_event import CreateSecurityEventRequest, SecurityEventResponse, UpdateSecurityEventRequest +from app.repositories.security_event_repository import SecurityEventRepository + +router = APIRouter(prefix="/security-events", tags=["security-events"]) + + +@router.post("", response_model=SecurityEventResponse, status_code=status.HTTP_201_CREATED) +async def create_security_event( + body: CreateSecurityEventRequest, db: AsyncSession = Depends(get_db) +) -> SecurityEventResponse: + repo = SecurityEventRepository(db) + return await repo.create(user_id=body.user_id, type=body.type, metadata_=body.metadata_) + + +@router.get("", response_model=list[SecurityEventResponse]) +async def list_security_events( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[SecurityEventResponse]: + repo = SecurityEventRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{event_id}", response_model=SecurityEventResponse) +async def get_security_event(event_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> SecurityEventResponse: + repo = SecurityEventRepository(db) + event = await repo.get_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail="Security event not found") + return event + + +@router.patch("/{event_id}", response_model=SecurityEventResponse) +async def update_security_event( + event_id: uuid.UUID, body: UpdateSecurityEventRequest, db: AsyncSession = Depends(get_db) +) -> SecurityEventResponse: + repo = SecurityEventRepository(db) + event = await repo.get_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail="Security event not found") + return await repo.update(event, type=body.type, metadata_=body.metadata_) + + +@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_security_event(event_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = SecurityEventRepository(db) + event = await repo.get_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail="Security event not found") + await repo.delete(event) diff --git a/backend/app/api/sesiones.py b/backend/app/api/sesiones.py new file mode 100644 index 0000000..81a4b3a --- /dev/null +++ b/backend/app/api/sesiones.py @@ -0,0 +1,65 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.sesion import CreateSesionRequest, SesionResponse, UpdateSesionRequest +from app.repositories.sesion_repository import SesionRepository + +router = APIRouter(prefix="/sesiones", tags=["sesiones"]) + + +@router.post("", response_model=SesionResponse, status_code=status.HTTP_201_CREATED) +async def create_sesion(body: CreateSesionRequest, db: AsyncSession = Depends(get_db)) -> SesionResponse: + repo = SesionRepository(db) + return await repo.create( + user_id=body.user_id, ip=body.ip, device_id=body.device_id, country=body.country, city=body.city + ) + + +@router.get("", response_model=list[SesionResponse]) +async def list_sesiones( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[SesionResponse]: + repo = SesionRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{sesion_id}", response_model=SesionResponse) +async def get_sesion(sesion_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> SesionResponse: + repo = SesionRepository(db) + sesion = await repo.get_by_id(sesion_id) + if not sesion: + raise HTTPException(status_code=404, detail="Sesion not found") + return sesion + + +@router.patch("/{sesion_id}", response_model=SesionResponse) +async def update_sesion( + sesion_id: uuid.UUID, body: UpdateSesionRequest, db: AsyncSession = Depends(get_db) +) -> SesionResponse: + repo = SesionRepository(db) + sesion = await repo.get_by_id(sesion_id) + if not sesion: + raise HTTPException(status_code=404, detail="Sesion not found") + return await repo.update(sesion, country=body.country, city=body.city, ended_at=body.ended_at) + + +@router.post("/{sesion_id}/end", response_model=SesionResponse) +async def end_sesion(sesion_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> SesionResponse: + repo = SesionRepository(db) + sesion = await repo.get_by_id(sesion_id) + if not sesion: + raise HTTPException(status_code=404, detail="Sesion not found") + return await repo.end(sesion) + + +@router.delete("/{sesion_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_sesion(sesion_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = SesionRepository(db) + sesion = await repo.get_by_id(sesion_id) + if not sesion: + raise HTTPException(status_code=404, detail="Sesion not found") + await repo.delete(sesion) diff --git a/backend/app/api/token_blacklist.py b/backend/app/api/token_blacklist.py new file mode 100644 index 0000000..55fabce --- /dev/null +++ b/backend/app/api/token_blacklist.py @@ -0,0 +1,56 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.token_blacklist import CreateTokenBlacklistRequest, TokenBlacklistResponse +from app.repositories.token_blacklist_repository import TokenBlacklistRepository + +router = APIRouter(prefix="/token-blacklist", tags=["token-blacklist"]) + + +@router.post("", response_model=TokenBlacklistResponse, status_code=status.HTTP_201_CREATED) +async def create_token_blacklist( + body: CreateTokenBlacklistRequest, db: AsyncSession = Depends(get_db) +) -> TokenBlacklistResponse: + repo = TokenBlacklistRepository(db) + return await repo.create( + token_jti=body.token_jti, expires_at=body.expires_at, user_id=body.user_id, reason=body.reason + ) + + +@router.get("", response_model=list[TokenBlacklistResponse]) +async def list_token_blacklist( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[TokenBlacklistResponse]: + repo = TokenBlacklistRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/by-jti/{token_jti}", response_model=TokenBlacklistResponse) +async def get_by_jti(token_jti: str, db: AsyncSession = Depends(get_db)) -> TokenBlacklistResponse: + repo = TokenBlacklistRepository(db) + entry = await repo.get_by_jti(token_jti) + if not entry: + raise HTTPException(status_code=404, detail="Token not found in blacklist") + return entry + + +@router.get("/{entry_id}", response_model=TokenBlacklistResponse) +async def get_token_blacklist(entry_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> TokenBlacklistResponse: + repo = TokenBlacklistRepository(db) + entry = await repo.get_by_id(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Token not found in blacklist") + return entry + + +@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_token_blacklist(entry_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = TokenBlacklistRepository(db) + entry = await repo.get_by_id(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Token not found in blacklist") + await repo.delete(entry) diff --git a/backend/app/api/transacciones.py b/backend/app/api/transacciones.py new file mode 100644 index 0000000..b790a2a --- /dev/null +++ b/backend/app/api/transacciones.py @@ -0,0 +1,65 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.transaccion import CreateTransaccionRequest, TransaccionResponse, UpdateTransaccionRequest +from app.repositories.transaccion_repository import TransaccionRepository + +router = APIRouter(prefix="/transacciones", tags=["transacciones"]) + + +@router.post("", response_model=TransaccionResponse, status_code=status.HTTP_201_CREATED) +async def create_transaccion( + body: CreateTransaccionRequest, db: AsyncSession = Depends(get_db) +) -> TransaccionResponse: + repo = TransaccionRepository(db) + return await repo.create( + user_id=body.user_id, + from_account_id=body.from_account_id, + to_account=body.to_account, + amount=body.amount, + currency=body.currency, + status=body.status, + ip=body.ip, + device_id=body.device_id, + ) + + +@router.get("", response_model=list[TransaccionResponse]) +async def list_transacciones( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[TransaccionResponse]: + repo = TransaccionRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{transaccion_id}", response_model=TransaccionResponse) +async def get_transaccion(transaccion_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> TransaccionResponse: + repo = TransaccionRepository(db) + transaccion = await repo.get_by_id(transaccion_id) + if not transaccion: + raise HTTPException(status_code=404, detail="Transaccion not found") + return transaccion + + +@router.patch("/{transaccion_id}", response_model=TransaccionResponse) +async def update_transaccion( + transaccion_id: uuid.UUID, body: UpdateTransaccionRequest, db: AsyncSession = Depends(get_db) +) -> TransaccionResponse: + repo = TransaccionRepository(db) + transaccion = await repo.get_by_id(transaccion_id) + if not transaccion: + raise HTTPException(status_code=404, detail="Transaccion not found") + return await repo.update(transaccion, status=body.status, ip=body.ip) + + +@router.delete("/{transaccion_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_transaccion(transaccion_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = TransaccionRepository(db) + transaccion = await repo.get_by_id(transaccion_id) + if not transaccion: + raise HTTPException(status_code=404, detail="Transaccion not found") + await repo.delete(transaccion) From 2d478596262d8c9692326dffa7d8fab4dcb7644c Mon Sep 17 00:00:00 2001 From: herrdelta83 Date: Fri, 10 Apr 2026 15:14:24 -0600 Subject: [PATCH 5/5] 10 tests --- backend/tests/test_audit_logs.py | 64 ++++++++++++++++++++ backend/tests/test_fraud_actions.py | 75 ++++++++++++++++++++++++ backend/tests/test_ip_reputations.py | 77 ++++++++++++++++++++++++ backend/tests/test_otp_challenges.py | 81 ++++++++++++++++++++++++++ backend/tests/test_risk_assessments.py | 76 ++++++++++++++++++++++++ backend/tests/test_risk_features.py | 76 ++++++++++++++++++++++++ backend/tests/test_security_events.py | 73 +++++++++++++++++++++++ backend/tests/test_sesiones.py | 71 ++++++++++++++++++++++ backend/tests/test_token_blacklist.py | 67 +++++++++++++++++++++ backend/tests/test_transacciones.py | 81 ++++++++++++++++++++++++++ 10 files changed, 741 insertions(+) create mode 100644 backend/tests/test_audit_logs.py create mode 100644 backend/tests/test_fraud_actions.py create mode 100644 backend/tests/test_ip_reputations.py create mode 100644 backend/tests/test_otp_challenges.py create mode 100644 backend/tests/test_risk_assessments.py create mode 100644 backend/tests/test_risk_features.py create mode 100644 backend/tests/test_security_events.py create mode 100644 backend/tests/test_sesiones.py create mode 100644 backend/tests/test_token_blacklist.py create mode 100644 backend/tests/test_transacciones.py diff --git a/backend/tests/test_audit_logs.py b/backend/tests/test_audit_logs.py new file mode 100644 index 0000000..ef90461 --- /dev/null +++ b/backend/tests/test_audit_logs.py @@ -0,0 +1,64 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_audit_log(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/audit-logs", + json={"user_id": user_id, "action": "login", "resource": "sesion", "details": {"browser": "Chrome"}}, + ) + assert response.status_code == 201 + data = response.json() + assert data["action"] == "login" + assert data["details"]["browser"] == "Chrome" + + +@pytest.mark.asyncio +async def test_create_audit_log_no_user(client: AsyncClient) -> None: + response = await client.post("/api/audit-logs", json={"action": "system_boot", "resource": "server"}) + assert response.status_code == 201 + assert response.json()["user_id"] is None + + +@pytest.mark.asyncio +async def test_get_audit_log(client: AsyncClient) -> None: + create_resp = await client.post("/api/audit-logs", json={"action": "read", "resource": "cuenta"}) + log_id = create_resp.json()["id"] + assert (await client.get(f"/api/audit-logs/{log_id}")).status_code == 200 + + +@pytest.mark.asyncio +async def test_get_audit_log_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/audit-logs/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_audit_logs_filtered(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/audit-logs", json={"user_id": user_a, "action": "login", "resource": "sesion"}) + await client.post("/api/audit-logs", json={"user_id": user_b, "action": "login", "resource": "sesion"}) + response = await client.get(f"/api/audit-logs?user_id={user_a}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_delete_audit_log(client: AsyncClient) -> None: + create_resp = await client.post("/api/audit-logs", json={"action": "del", "resource": "X"}) + log_id = create_resp.json()["id"] + assert (await client.delete(f"/api/audit-logs/{log_id}")).status_code == 204 + assert (await client.get(f"/api/audit-logs/{log_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_audit_log_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/audit-logs/{uuid.uuid4()}")).status_code == 404 diff --git a/backend/tests/test_fraud_actions.py b/backend/tests/test_fraud_actions.py new file mode 100644 index 0000000..79bea7b --- /dev/null +++ b/backend/tests/test_fraud_actions.py @@ -0,0 +1,75 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +async def _create_transaccion(client: AsyncClient, user_id: str) -> str: + resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT", "amount": "100.00", "currency": "MXN"}, + ) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_fraud_action(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + response = await client.post( + "/api/fraud-actions", + json={"transaction_id": tx_id, "action_type": "block_account", "status": "pending"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["action_type"] == "block_account" + assert data["status"] == "pending" + + +@pytest.mark.asyncio +async def test_get_fraud_action(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/fraud-actions", json={"transaction_id": tx_id, "action_type": "flag"}) + action_id = create_resp.json()["id"] + assert (await client.get(f"/api/fraud-actions/{action_id}")).status_code == 200 + + +@pytest.mark.asyncio +async def test_get_fraud_action_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/fraud-actions/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_fraud_actions_by_transaction(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + await client.post("/api/fraud-actions", json={"transaction_id": tx_id, "action_type": "notify"}) + response = await client.get(f"/api/fraud-actions?transaction_id={tx_id}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_fraud_action(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/fraud-actions", json={"transaction_id": tx_id, "action_type": "hold"}) + action_id = create_resp.json()["id"] + response = await client.patch(f"/api/fraud-actions/{action_id}", json={"status": "executed"}) + assert response.status_code == 200 + assert response.json()["status"] == "executed" + + +@pytest.mark.asyncio +async def test_delete_fraud_action(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/fraud-actions", json={"transaction_id": tx_id, "action_type": "del"}) + action_id = create_resp.json()["id"] + assert (await client.delete(f"/api/fraud-actions/{action_id}")).status_code == 204 + assert (await client.get(f"/api/fraud-actions/{action_id}")).status_code == 404 diff --git a/backend/tests/test_ip_reputations.py b/backend/tests/test_ip_reputations.py new file mode 100644 index 0000000..91223e4 --- /dev/null +++ b/backend/tests/test_ip_reputations.py @@ -0,0 +1,77 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_create_ip_reputation(client: AsyncClient) -> None: + response = await client.post( + "/api/ip-reputations", + json={"ip": "1.2.3.4", "risk_score": "0.75", "status": "suspicious", "failed_attempts": 3}, + ) + assert response.status_code == 201 + data = response.json() + assert data["ip"] == "1.2.3.4" + assert data["status"] == "suspicious" + assert data["failed_attempts"] == 3 + + +@pytest.mark.asyncio +async def test_create_ip_reputation_duplicate(client: AsyncClient) -> None: + await client.post("/api/ip-reputations", json={"ip": "5.5.5.5"}) + response = await client.post("/api/ip-reputations", json={"ip": "5.5.5.5"}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_get_ip_reputation(client: AsyncClient) -> None: + create_resp = await client.post("/api/ip-reputations", json={"ip": "2.3.4.5"}) + entry_id = create_resp.json()["id"] + assert (await client.get(f"/api/ip-reputations/{entry_id}")).status_code == 200 + + +@pytest.mark.asyncio +async def test_get_ip_reputation_by_ip(client: AsyncClient) -> None: + await client.post("/api/ip-reputations", json={"ip": "9.9.9.9"}) + response = await client.get("/api/ip-reputations/by-ip/9.9.9.9") + assert response.status_code == 200 + assert response.json()["ip"] == "9.9.9.9" + + +@pytest.mark.asyncio +async def test_get_ip_reputation_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/ip-reputations/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_ip_reputations(client: AsyncClient) -> None: + await client.post("/api/ip-reputations", json={"ip": "10.0.0.1"}) + await client.post("/api/ip-reputations", json={"ip": "10.0.0.2"}) + response = await client.get("/api/ip-reputations") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.asyncio +async def test_update_ip_reputation(client: AsyncClient) -> None: + create_resp = await client.post("/api/ip-reputations", json={"ip": "11.0.0.1", "failed_attempts": 0}) + entry_id = create_resp.json()["id"] + response = await client.patch(f"/api/ip-reputations/{entry_id}", json={"failed_attempts": 5, "status": "blocked"}) + assert response.status_code == 200 + data = response.json() + assert data["failed_attempts"] == 5 + assert data["status"] == "blocked" + + +@pytest.mark.asyncio +async def test_delete_ip_reputation(client: AsyncClient) -> None: + create_resp = await client.post("/api/ip-reputations", json={"ip": "12.0.0.1"}) + entry_id = create_resp.json()["id"] + assert (await client.delete(f"/api/ip-reputations/{entry_id}")).status_code == 204 + assert (await client.get(f"/api/ip-reputations/{entry_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_ip_reputation_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/ip-reputations/{uuid.uuid4()}")).status_code == 404 diff --git a/backend/tests/test_otp_challenges.py b/backend/tests/test_otp_challenges.py new file mode 100644 index 0000000..d5b7c31 --- /dev/null +++ b/backend/tests/test_otp_challenges.py @@ -0,0 +1,81 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +def _future() -> str: + return (datetime.now(UTC) + timedelta(minutes=10)).isoformat() + + +@pytest.mark.asyncio +async def test_create_otp_challenge(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/otp-challenges", + json={"user_id": user_id, "code_hash": "abc123", "channel": "sms", "expires_at": _future()}, + ) + assert response.status_code == 201 + data = response.json() + assert data["channel"] == "sms" + assert data["status"] == "pending" + assert data["attempts"] == 0 + assert data["max_attempts"] == 3 + + +@pytest.mark.asyncio +async def test_get_otp_challenge(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/otp-challenges", + json={"user_id": user_id, "code_hash": "xyz", "channel": "email", "expires_at": _future()}, + ) + challenge_id = create_resp.json()["id"] + assert (await client.get(f"/api/otp-challenges/{challenge_id}")).status_code == 200 + + +@pytest.mark.asyncio +async def test_get_otp_challenge_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/otp-challenges/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_otp_challenges_filtered(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/otp-challenges", json={"user_id": user_a, "code_hash": "h1", "channel": "sms", "expires_at": _future()}) + await client.post("/api/otp-challenges", json={"user_id": user_b, "code_hash": "h2", "channel": "sms", "expires_at": _future()}) + response = await client.get(f"/api/otp-challenges?user_id={user_a}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_otp_challenge(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/otp-challenges", + json={"user_id": user_id, "code_hash": "h3", "channel": "sms", "expires_at": _future()}, + ) + challenge_id = create_resp.json()["id"] + response = await client.patch(f"/api/otp-challenges/{challenge_id}", json={"status": "verified", "attempts": 1}) + assert response.status_code == 200 + assert response.json()["status"] == "verified" + assert response.json()["attempts"] == 1 + + +@pytest.mark.asyncio +async def test_delete_otp_challenge(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/otp-challenges", + json={"user_id": user_id, "code_hash": "del", "channel": "sms", "expires_at": _future()}, + ) + challenge_id = create_resp.json()["id"] + assert (await client.delete(f"/api/otp-challenges/{challenge_id}")).status_code == 204 + assert (await client.get(f"/api/otp-challenges/{challenge_id}")).status_code == 404 diff --git a/backend/tests/test_risk_assessments.py b/backend/tests/test_risk_assessments.py new file mode 100644 index 0000000..9510e0b --- /dev/null +++ b/backend/tests/test_risk_assessments.py @@ -0,0 +1,76 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +async def _create_transaccion(client: AsyncClient, user_id: str) -> str: + resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT", "amount": "100.00", "currency": "MXN"}, + ) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_risk_assessment(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + response = await client.post( + "/api/risk-assessments", + json={"transaction_id": tx_id, "risk_score": "0.85", "risk_level": "high", "decision": "block"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["risk_level"] == "high" + assert data["decision"] == "block" + + +@pytest.mark.asyncio +async def test_get_risk_assessment(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-assessments", json={"transaction_id": tx_id, "decision": "approve"}) + assessment_id = create_resp.json()["id"] + response = await client.get(f"/api/risk-assessments/{assessment_id}") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_get_risk_assessment_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/risk-assessments/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_risk_assessments_by_transaction(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + await client.post("/api/risk-assessments", json={"transaction_id": tx_id, "decision": "approve"}) + response = await client.get(f"/api/risk-assessments?transaction_id={tx_id}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_risk_assessment(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-assessments", json={"transaction_id": tx_id, "decision": "review"}) + assessment_id = create_resp.json()["id"] + response = await client.patch(f"/api/risk-assessments/{assessment_id}", json={"decision": "block"}) + assert response.status_code == 200 + assert response.json()["decision"] == "block" + + +@pytest.mark.asyncio +async def test_delete_risk_assessment(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-assessments", json={"transaction_id": tx_id}) + assessment_id = create_resp.json()["id"] + assert (await client.delete(f"/api/risk-assessments/{assessment_id}")).status_code == 204 + assert (await client.get(f"/api/risk-assessments/{assessment_id}")).status_code == 404 diff --git a/backend/tests/test_risk_features.py b/backend/tests/test_risk_features.py new file mode 100644 index 0000000..0c47be5 --- /dev/null +++ b/backend/tests/test_risk_features.py @@ -0,0 +1,76 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +async def _create_transaccion(client: AsyncClient, user_id: str) -> str: + resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT", "amount": "100.00", "currency": "MXN"}, + ) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_risk_feature(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + response = await client.post( + "/api/risk-features", + json={"transaction_id": tx_id, "velocity_1m": 3, "velocity_1h": 10, "new_beneficiary": True}, + ) + assert response.status_code == 201 + data = response.json() + assert data["velocity_1m"] == 3 + assert data["new_beneficiary"] is True + + +@pytest.mark.asyncio +async def test_get_risk_feature(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-features", json={"transaction_id": tx_id, "velocity_1m": 1}) + feature_id = create_resp.json()["id"] + response = await client.get(f"/api/risk-features/{feature_id}") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_get_risk_feature_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/risk-features/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_risk_features_by_transaction(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + await client.post("/api/risk-features", json={"transaction_id": tx_id, "velocity_1m": 2}) + response = await client.get(f"/api/risk-features?transaction_id={tx_id}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_risk_feature(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-features", json={"transaction_id": tx_id, "velocity_1m": 1}) + feature_id = create_resp.json()["id"] + response = await client.patch(f"/api/risk-features/{feature_id}", json={"velocity_1m": 99}) + assert response.status_code == 200 + assert response.json()["velocity_1m"] == 99 + + +@pytest.mark.asyncio +async def test_delete_risk_feature(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + tx_id = await _create_transaccion(client, user_id) + create_resp = await client.post("/api/risk-features", json={"transaction_id": tx_id}) + feature_id = create_resp.json()["id"] + assert (await client.delete(f"/api/risk-features/{feature_id}")).status_code == 204 + assert (await client.get(f"/api/risk-features/{feature_id}")).status_code == 404 diff --git a/backend/tests/test_security_events.py b/backend/tests/test_security_events.py new file mode 100644 index 0000000..4bf5842 --- /dev/null +++ b/backend/tests/test_security_events.py @@ -0,0 +1,73 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_security_event(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/security-events", + json={"user_id": user_id, "type": "login_failed", "metadata_": {"ip": "1.2.3.4"}}, + ) + assert response.status_code == 201 + data = response.json() + assert data["user_id"] == user_id + assert data["type"] == "login_failed" + + +@pytest.mark.asyncio +async def test_get_security_event(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/security-events", json={"user_id": user_id, "type": "suspicious_tx"}) + event_id = create_resp.json()["id"] + response = await client.get(f"/api/security-events/{event_id}") + assert response.status_code == 200 + assert response.json()["type"] == "suspicious_tx" + + +@pytest.mark.asyncio +async def test_get_security_event_not_found(client: AsyncClient) -> None: + response = await client.get(f"/api/security-events/{uuid.uuid4()}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_security_events_filtered(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/security-events", json={"user_id": user_a, "type": "login"}) + await client.post("/api/security-events", json={"user_id": user_b, "type": "logout"}) + response = await client.get(f"/api/security-events?user_id={user_a}") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_security_event(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/security-events", json={"user_id": user_id, "type": "old_type"}) + event_id = create_resp.json()["id"] + response = await client.patch(f"/api/security-events/{event_id}", json={"type": "new_type"}) + assert response.status_code == 200 + assert response.json()["type"] == "new_type" + + +@pytest.mark.asyncio +async def test_delete_security_event(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/security-events", json={"user_id": user_id, "type": "del"}) + event_id = create_resp.json()["id"] + assert (await client.delete(f"/api/security-events/{event_id}")).status_code == 204 + assert (await client.get(f"/api/security-events/{event_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_security_event_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/security-events/{uuid.uuid4()}")).status_code == 404 diff --git a/backend/tests/test_sesiones.py b/backend/tests/test_sesiones.py new file mode 100644 index 0000000..59c7f37 --- /dev/null +++ b/backend/tests/test_sesiones.py @@ -0,0 +1,71 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_sesion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/sesiones", + json={"user_id": user_id, "ip": "192.168.1.1", "country": "MX", "city": "CDMX"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["ip"] == "192.168.1.1" + assert data["country"] == "MX" + assert data["ended_at"] is None + + +@pytest.mark.asyncio +async def test_get_sesion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/sesiones", json={"user_id": user_id, "ip": "10.0.0.1"}) + sesion_id = create_resp.json()["id"] + response = await client.get(f"/api/sesiones/{sesion_id}") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_get_sesion_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/sesiones/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_sesiones_filtered(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/sesiones", json={"user_id": user_a, "ip": "1.1.1.1"}) + await client.post("/api/sesiones", json={"user_id": user_b, "ip": "2.2.2.2"}) + response = await client.get(f"/api/sesiones?user_id={user_a}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_end_sesion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/sesiones", json={"user_id": user_id, "ip": "3.3.3.3"}) + sesion_id = create_resp.json()["id"] + response = await client.post(f"/api/sesiones/{sesion_id}/end") + assert response.status_code == 200 + assert response.json()["ended_at"] is not None + + +@pytest.mark.asyncio +async def test_delete_sesion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post("/api/sesiones", json={"user_id": user_id, "ip": "4.4.4.4"}) + sesion_id = create_resp.json()["id"] + assert (await client.delete(f"/api/sesiones/{sesion_id}")).status_code == 204 + assert (await client.get(f"/api/sesiones/{sesion_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_sesion_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/sesiones/{uuid.uuid4()}")).status_code == 404 diff --git a/backend/tests/test_token_blacklist.py b/backend/tests/test_token_blacklist.py new file mode 100644 index 0000000..1d28b39 --- /dev/null +++ b/backend/tests/test_token_blacklist.py @@ -0,0 +1,67 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from httpx import AsyncClient + + +def _future() -> str: + return (datetime.now(UTC) + timedelta(hours=1)).isoformat() + + +@pytest.mark.asyncio +async def test_create_token_blacklist(client: AsyncClient) -> None: + jti = str(uuid.uuid4()) + response = await client.post( + "/api/token-blacklist", + json={"token_jti": jti, "expires_at": _future(), "reason": "logout"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["token_jti"] == jti + assert data["reason"] == "logout" + + +@pytest.mark.asyncio +async def test_get_token_blacklist_by_id(client: AsyncClient) -> None: + jti = str(uuid.uuid4()) + create_resp = await client.post("/api/token-blacklist", json={"token_jti": jti, "expires_at": _future()}) + entry_id = create_resp.json()["id"] + assert (await client.get(f"/api/token-blacklist/{entry_id}")).status_code == 200 + + +@pytest.mark.asyncio +async def test_get_token_blacklist_by_jti(client: AsyncClient) -> None: + jti = str(uuid.uuid4()) + await client.post("/api/token-blacklist", json={"token_jti": jti, "expires_at": _future()}) + response = await client.get(f"/api/token-blacklist/by-jti/{jti}") + assert response.status_code == 200 + assert response.json()["token_jti"] == jti + + +@pytest.mark.asyncio +async def test_get_token_blacklist_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/token-blacklist/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_token_blacklist(client: AsyncClient) -> None: + await client.post("/api/token-blacklist", json={"token_jti": str(uuid.uuid4()), "expires_at": _future()}) + await client.post("/api/token-blacklist", json={"token_jti": str(uuid.uuid4()), "expires_at": _future()}) + response = await client.get("/api/token-blacklist") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.asyncio +async def test_delete_token_blacklist(client: AsyncClient) -> None: + jti = str(uuid.uuid4()) + create_resp = await client.post("/api/token-blacklist", json={"token_jti": jti, "expires_at": _future()}) + entry_id = create_resp.json()["id"] + assert (await client.delete(f"/api/token-blacklist/{entry_id}")).status_code == 204 + assert (await client.get(f"/api/token-blacklist/{entry_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_token_blacklist_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/token-blacklist/{uuid.uuid4()}")).status_code == 404 diff --git a/backend/tests/test_transacciones.py b/backend/tests/test_transacciones.py new file mode 100644 index 0000000..f18171c --- /dev/null +++ b/backend/tests/test_transacciones.py @@ -0,0 +1,81 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_transaccion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT-001", "amount": "500.00", "currency": "MXN"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["to_account"] == "EXT-001" + assert data["status"] == "pending" + assert data["amount"] == "500.00" + + +@pytest.mark.asyncio +async def test_get_transaccion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT-002", "amount": "100.00", "currency": "USD"}, + ) + tx_id = create_resp.json()["id"] + response = await client.get(f"/api/transacciones/{tx_id}") + assert response.status_code == 200 + assert response.json()["currency"] == "USD" + + +@pytest.mark.asyncio +async def test_get_transaccion_not_found(client: AsyncClient) -> None: + assert (await client.get(f"/api/transacciones/{uuid.uuid4()}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_list_transacciones_filtered(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/transacciones", json={"user_id": user_a, "to_account": "X", "amount": "1.00", "currency": "MXN"}) + await client.post("/api/transacciones", json={"user_id": user_b, "to_account": "Y", "amount": "2.00", "currency": "MXN"}) + response = await client.get(f"/api/transacciones?user_id={user_a}") + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_update_transaccion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT-003", "amount": "250.00", "currency": "MXN"}, + ) + tx_id = create_resp.json()["id"] + response = await client.patch(f"/api/transacciones/{tx_id}", json={"status": "completed"}) + assert response.status_code == 200 + assert response.json()["status"] == "completed" + + +@pytest.mark.asyncio +async def test_delete_transaccion(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/transacciones", + json={"user_id": user_id, "to_account": "EXT-DEL", "amount": "10.00", "currency": "MXN"}, + ) + tx_id = create_resp.json()["id"] + assert (await client.delete(f"/api/transacciones/{tx_id}")).status_code == 204 + assert (await client.get(f"/api/transacciones/{tx_id}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_transaccion_not_found(client: AsyncClient) -> None: + assert (await client.delete(f"/api/transacciones/{uuid.uuid4()}")).status_code == 404