From b396b6d3c99afc374a4143f88c6bc51fe27e7ac0 Mon Sep 17 00:00:00 2001 From: rikrdo Date: Mon, 18 May 2026 00:26:32 +0200 Subject: [PATCH] refactor: make ARNES external-repo based with ticket publish flow --- AGENTS.local.md.example | 14 +- AGENTS.md | 19 +- CHECKPOINTS.md | 5 +- HOWTO-FEATURE.md | 259 ++-------- HOWTO.md | 24 +- Makefile | 45 +- README-UI.md | 57 --- README.md | 66 ++- TEMPLATE.md | 9 +- backlog/features.json | 25 +- docs/repository-layout.md | 38 ++ docs/scripts-reference.md | 66 +++ features/README.md | 11 + features/behave.ini | 8 +- features/steps/.gitkeep | 0 features/steps/auth_steps.py | 198 -------- features/steps/password_steps.py | 470 ------------------ features/steps/profile_steps.py | 431 ---------------- harness/agents.matrix.yml | 18 +- harness/workflow.stages.yml | 7 + project/README.md | 11 + pytest.ini | 6 - requirements.txt | 9 +- scripts/agent_status.py | 46 +- scripts/install_into_repo.sh | 66 +++ scripts/new_ticket.py | 50 +- scripts/publish_ticket.py | 133 +++++ scripts/run.sh | 36 -- scripts/start.sh | 64 ++- scripts/test_api.py | 93 ---- scripts/verify.sh | 95 +++- spec/bdd/README.md | 11 +- spec/bdd/features/.gitkeep | 0 spec/bdd/features/README.md | 64 +-- spec/bdd/features/auth/login.feature | 70 --- spec/bdd/features/auth/logout.feature | 58 --- spec/bdd/features/common/README.md | 36 -- .../features/password/change-password.feature | 171 ------- .../bdd/features/profile/user-profile.feature | 159 ------ spec/sdd-bdd-guide.md | 24 +- spec/sdd/components/.gitkeep | 0 spec/sdd/components/.template.md | 74 --- spec/sdd/components/README.md | 8 + spec/sdd/components/auth-service.md | 65 --- spec/sdd/components/password-service.md | 114 ----- spec/sdd/components/session-store.md | 75 --- spec/sdd/components/token-service.md | 69 --- spec/sdd/components/user-profile-service.md | 111 ----- spec/sdd/decisions/.gitkeep | 0 spec/sdd/decisions/.template.md | 48 -- spec/sdd/decisions/001-stack-tecnologico.md | 63 --- .../decisions/002-almacenamiento-avatar.md | 69 --- spec/sdd/decisions/003-hashing-contrasena.md | 83 ---- spec/sdd/decisions/004-jwt-auth.md | 68 --- spec/sdd/decisions/README.md | 7 + src/__init__.py | 1 - src/__pycache__/__init__.cpython-313.pyc | Bin 168 -> 0 bytes src/__pycache__/main.cpython-313.pyc | Bin 4581 -> 0 bytes src/api/__init__.py | 1 - src/api/__pycache__/__init__.cpython-313.pyc | Bin 168 -> 0 bytes src/api/__pycache__/auth.cpython-313.pyc | Bin 7198 -> 0 bytes src/api/__pycache__/password.cpython-313.pyc | Bin 3336 -> 0 bytes src/api/auth.py | 220 -------- src/api/main.py | 80 --- src/api/password.py | 89 ---- src/main.py | 116 ----- src/models/__init__.py | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 174 -> 0 bytes src/models/__pycache__/auth.cpython-313.pyc | Bin 4334 -> 0 bytes .../__pycache__/password.cpython-313.pyc | Bin 3206 -> 0 bytes .../__pycache__/profile.cpython-313.pyc | Bin 4548 -> 0 bytes src/models/auth.py | 63 --- src/models/password.py | 49 -- src/models/profile.py | 75 --- src/services/__init__.py | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 178 -> 0 bytes .../__pycache__/auth_service.cpython-313.pyc | Bin 10644 -> 0 bytes .../password_service.cpython-313.pyc | Bin 7404 -> 0 bytes .../profile_service.cpython-313.pyc | Bin 3056 -> 0 bytes .../__pycache__/session_store.cpython-313.pyc | Bin 5603 -> 0 bytes .../__pycache__/token_service.cpython-313.pyc | Bin 5163 -> 0 bytes src/services/auth_service.py | 298 ----------- src/services/password_service.py | 168 ------- src/services/profile_service.py | 86 ---- src/services/session_store.py | 130 ----- src/services/token_service.py | 121 ----- src/ui/change-password.html | 359 ------------- src/ui/dashboard.html | 329 ------------ src/ui/login.html | 325 ------------ starter-pack/README.md | 8 +- starter-pack/backlog.features.bootstrap.json | 24 +- tests/__init__.py | 1 - tests/__pycache__/__init__.cpython-313.pyc | Bin 173 -> 0 bytes tests/unit/__init__.py | 1 - .../unit/__pycache__/__init__.cpython-313.pyc | Bin 178 -> 0 bytes .../__pycache__/test_auth.cpython-313.pyc | Bin 14126 -> 0 bytes .../__pycache__/test_password.cpython-313.pyc | Bin 9586 -> 0 bytes .../__pycache__/test_profile.cpython-313.pyc | Bin 9497 -> 0 bytes tests/unit/test_auth.py | 262 ---------- tests/unit/test_password.py | 185 ------- tests/unit/test_profile.py | 131 ----- 101 files changed, 810 insertions(+), 6140 deletions(-) delete mode 100644 README-UI.md create mode 100644 docs/repository-layout.md create mode 100644 docs/scripts-reference.md create mode 100644 features/README.md create mode 100644 features/steps/.gitkeep delete mode 100644 features/steps/auth_steps.py delete mode 100644 features/steps/password_steps.py delete mode 100644 features/steps/profile_steps.py create mode 100644 project/README.md delete mode 100644 pytest.ini create mode 100755 scripts/install_into_repo.sh create mode 100755 scripts/publish_ticket.py delete mode 100755 scripts/run.sh delete mode 100644 scripts/test_api.py create mode 100644 spec/bdd/features/.gitkeep delete mode 100644 spec/bdd/features/auth/login.feature delete mode 100644 spec/bdd/features/auth/logout.feature delete mode 100644 spec/bdd/features/common/README.md delete mode 100644 spec/bdd/features/password/change-password.feature delete mode 100644 spec/bdd/features/profile/user-profile.feature create mode 100644 spec/sdd/components/.gitkeep delete mode 100644 spec/sdd/components/.template.md create mode 100644 spec/sdd/components/README.md delete mode 100644 spec/sdd/components/auth-service.md delete mode 100644 spec/sdd/components/password-service.md delete mode 100644 spec/sdd/components/session-store.md delete mode 100644 spec/sdd/components/token-service.md delete mode 100644 spec/sdd/components/user-profile-service.md create mode 100644 spec/sdd/decisions/.gitkeep delete mode 100644 spec/sdd/decisions/.template.md delete mode 100644 spec/sdd/decisions/001-stack-tecnologico.md delete mode 100644 spec/sdd/decisions/002-almacenamiento-avatar.md delete mode 100644 spec/sdd/decisions/003-hashing-contrasena.md delete mode 100644 spec/sdd/decisions/004-jwt-auth.md create mode 100644 spec/sdd/decisions/README.md delete mode 100644 src/__init__.py delete mode 100644 src/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/__pycache__/main.cpython-313.pyc delete mode 100644 src/api/__init__.py delete mode 100644 src/api/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/api/__pycache__/auth.cpython-313.pyc delete mode 100644 src/api/__pycache__/password.cpython-313.pyc delete mode 100644 src/api/auth.py delete mode 100644 src/api/main.py delete mode 100644 src/api/password.py delete mode 100644 src/main.py delete mode 100644 src/models/__init__.py delete mode 100644 src/models/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/models/__pycache__/auth.cpython-313.pyc delete mode 100644 src/models/__pycache__/password.cpython-313.pyc delete mode 100644 src/models/__pycache__/profile.cpython-313.pyc delete mode 100644 src/models/auth.py delete mode 100644 src/models/password.py delete mode 100644 src/models/profile.py delete mode 100644 src/services/__init__.py delete mode 100644 src/services/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/services/__pycache__/auth_service.cpython-313.pyc delete mode 100644 src/services/__pycache__/password_service.cpython-313.pyc delete mode 100644 src/services/__pycache__/profile_service.cpython-313.pyc delete mode 100644 src/services/__pycache__/session_store.cpython-313.pyc delete mode 100644 src/services/__pycache__/token_service.cpython-313.pyc delete mode 100644 src/services/auth_service.py delete mode 100644 src/services/password_service.py delete mode 100644 src/services/profile_service.py delete mode 100644 src/services/session_store.py delete mode 100644 src/services/token_service.py delete mode 100644 src/ui/change-password.html delete mode 100644 src/ui/dashboard.html delete mode 100644 src/ui/login.html delete mode 100644 tests/__init__.py delete mode 100644 tests/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/unit/__pycache__/test_auth.cpython-313.pyc delete mode 100644 tests/unit/__pycache__/test_password.cpython-313.pyc delete mode 100644 tests/unit/__pycache__/test_profile.cpython-313.pyc delete mode 100644 tests/unit/test_auth.py delete mode 100644 tests/unit/test_password.py delete mode 100644 tests/unit/test_profile.py diff --git a/AGENTS.local.md.example b/AGENTS.local.md.example index 8bb47a9..f1c0761 100644 --- a/AGENTS.local.md.example +++ b/AGENTS.local.md.example @@ -1,9 +1,9 @@ -# AGENTS.local.md (ejemplo opcional) +# AGENTS.local.md (optional example) -Este archivo define reglas específicas del proyecto actual. +Use this file for project-specific rules only. -## Ejemplo -- Stack: FastAPI + PostgreSQL -- Deploy: Kubernetes -- Regla extra: toda migración requiere evidencia en `work/artifacts//db.md` -- Regla extra: `scripts/verify.local.sh` debe ejecutar `alembic check` y `pytest -m smoke` +## Example +- App dir: `project/` +- Deploy target: staging Kubernetes cluster +- Extra rule: DB changes require `work/artifacts//db.md` +- Extra rule: `scripts/verify.local.sh` must run smoke tests diff --git a/AGENTS.md b/AGENTS.md index 9437dd3..dcc0071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,13 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso. ## Arranque obligatorio -1. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`. -2. Leer `work/current.md`. -3. Leer `backlog/features.json` y seleccionar **una** feature `pending`. -4. Ejecutar `./scripts/verify.sh`. -5. Mostrar estado runtime: `python3 scripts/agent_status.py show`. -6. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`. +1. Usar ARNES dentro de un repo de proyecto real, no dentro del repo fuente de ARNES. +2. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`. +3. Leer `work/current.md`. +4. Leer `backlog/features.json` y seleccionar **una** feature `pending`. +5. Ejecutar `./scripts/verify.sh`. +6. Mostrar estado runtime: `python3 scripts/agent_status.py show`. +7. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`. ## Ticket creation policy - Tickets are created by `leader` (or `triager`) only. @@ -30,6 +31,7 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en - `implementer` nunca marca `done`. - `done` requiere gates aprobados: `reviewer`, `security`, `qa`. - `done` requiere evidencia de `documenter`: `work/artifacts//documenter.md`. +- `done` requiere publish final con commit+push del ticket: `work/artifacts//publish.json`. - Si `verify.sh` falla, no se cierra la feature. ## Modelo por tarea (token-aware) @@ -37,6 +39,11 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en - Routing config: `harness/models.profiles.yml` - Rules: `harness/policies/model-routing.md` +## Git publish por ticket +- Al terminar una feature/ticket, `leader` debe ejecutar: + - `python3 scripts/publish_ticket.py --feature-id F-123` +- Esto crea commit + push del ticket y deja evidencia en `work/artifacts//publish.json`. + ## Extensión por proyecto (overlay) - Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual. - Opcional: `scripts/verify.local.sh` para checks de dominio. diff --git a/CHECKPOINTS.md b/CHECKPOINTS.md index 43da744..6ae8849 100644 --- a/CHECKPOINTS.md +++ b/CHECKPOINTS.md @@ -1,11 +1,13 @@ # CHECKPOINTS ## C1 — Estructura -- [ ] Existe `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +- [ ] Existe `project/`, `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +- [ ] `project/README.md` existe como placeholder mínimo. ## C2 — Estado - [ ] Máximo una feature en `in_progress`. - [ ] Estados válidos en backlog. +- [ ] Tipos de ticket válidos en backlog. - [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`. ## C3 — Gates @@ -14,6 +16,7 @@ - [ ] Toda feature `done` tiene `qa.json` aprobado. - [ ] Toda feature `done` tiene `leader-close.json` válido. - [ ] Toda feature `done` tiene `documenter.md`. +- [ ] Toda feature `done` tiene `publish.json` con commit+push del ticket. ## C4 — Verificación - [ ] `./scripts/verify.sh` termina en OK. diff --git a/HOWTO-FEATURE.md b/HOWTO-FEATURE.md index 3853658..3240676 100644 --- a/HOWTO-FEATURE.md +++ b/HOWTO-FEATURE.md @@ -1,228 +1,41 @@ -# Cómo crear una Feature con SDD y BDD +# HOWTO-FEATURE — Crear una feature con SDD y BDD -Guía paso a paso para crear una feature usando System Design Document y Behavior Driven Development. +## Flujo corto +1. Crear ticket en backlog (`python3 scripts/new_ticket.py`) +2. `design` (architect) +3. `build` (implementer) +4. `review/security/qa` +5. `documentation_gate` +6. `close` +7. `publish` (`python3 scripts/publish_ticket.py --feature-id F-001`) ---- +## Artefactos esperados +- `work/artifacts//triage.md` (opcional) +- `work/artifacts//architect.md` (opcional) +- `work/artifacts//implementer.md` +- `work/artifacts//reviewer.json` +- `work/artifacts//security.json` +- `work/artifacts//qa.json` +- `work/artifacts//documenter.md` +- `work/artifacts//leader-close.json` +- `work/artifacts//publish.json` -## 📋 Flujo general +## Ticket style +- English caveman +- short title +- short acceptance bullets +- clear scope in/out -``` -1. Analizar la feature del backlog - ↓ -2. Crear SPEC/BBD (architect) - ↓ -3. Crear/actualizar SDD (architect) - ↓ -4. Generar código + tests (implementer) - ↓ -5. Review, Security, QA gates - ↓ -6. Cerrar feature -``` +## BDD notes +- Put `.feature` files in `spec/bdd/features/` +- Put steps in `features/steps/` +- Use tags like `@F-001`, `@smoke`, `@regression` ---- - -## Paso 1: Analizar del Backlog - -Ejemplo: F-002 "Gestión de Perfil de Usuario" - -```json -{ - "id": "F-002", - "title": "Gestión de Perfil de Usuario", - "description": "El usuario puede ver y editar su perfil (nombre, avatar, preferencias).", - "acceptance": [ - "Usuario puede ver su perfil", - "Usuario puede editar nombre y avatar", - "Usuario puede cambiar preferencias de idioma", - "Validación de datos en todos los campos" - ] -} -``` - ---- - -## Paso 2: Crear SDD (System Design Document) - -### 2.1 Crear componente - -Archivo: `spec/sdd/components/user-profile-service.md` - -```markdown -# Component: UserProfileService - -## Responsabilidad -Gestionar el perfil de usuario: consulta, actualización de datos básicos y preferencias. - -## Tipo -- [x] Microservicio - -## Interfaces - -### API REST - -``` -GET /api/v1/users/{user_id}/profile -Output: { "id", "name", "avatar_url", "language", "created_at" } - -PUT /api/v1/users/{user_id}/profile -Input: { "name": string, "avatar_url": string, "language": string } -Output: { "id", "name", "avatar_url", "language", "updated_at" } -``` - -## Validaciones -- name: 2-50 caracteres, sin caracteres especiales -- avatar_url: URL válida (http/https) -- language: enum ['en', 'es', 'fr', 'de'] -``` - -### 2.2 Crear ADR (si hay decisión técnica) - -Archivo: `spec/sdd/decisions/002-almacenamiento-avatar.md` - ---- - -## Paso 3: Crear BDD (Behavior Driven Development) - -### 3.1 Crear archivo .feature - -Archivo: `spec/bdd/features/profile/user-profile.feature` - -```gherkin -@F-002 @profile -Feature: Gestión de Perfil de Usuario - - Como usuario autenticado - Quiero gestionar mi perfil - Para mantener mis datos actualizados - - @smoke - Scenario: Ver perfil de usuario - Given un usuario autenticado con ID "user-123" - When el usuario solicita ver su perfil - Then el sistema retorna datos del perfil - And incluye nombre, avatar y preferencias - - Scenario: Editar nombre del perfil - Given un usuario autenticado con ID "user-123" - And el perfil tiene nombre "Juan" - When el usuario actualiza su nombre a "Pedro" - Then el perfil muestra nombre "Pedro" - And la fecha de actualización se registra - - @negative - Scenario: Editar nombre con caracteres inválidos - Given un usuario autenticado - When intenta cambiar nombre a "Juan@123!" - Then el sistema muestra error "Nombre inválido" - And el nombre permanece sin cambios - - Scenario: Cambiar idioma a español - Given un usuario con idioma "en" - When cambia idioma a "es" - Then toda la interfaz se muestra en español - And el preference se guarda correctamente -``` - -### 3.2 Escribir Step Definitions - -Archivo: `features/steps/profile_steps.py` - -```python -from behave import given, when, then - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated(context, user_id): - context.user_id = user_id - context.auth_token = f"token_{user_id}" - -@when('el usuario solicita ver su perfil') -def step_get_profile(context): - profile_service = ProfileService() - context.profile = profile_service.get_profile(context.user_id) - -@then('el sistema retorna datos del perfil') -def step_return_profile(context): - assert context.profile is not None - assert "name" in context.profile - -# ... más steps -``` - ---- - -## Paso 4: Ejecutar el pipeline ARNES - -### Stage: design (architect) -- ✅ Crea SDD component -- ✅ Crea BDD feature -- ✅ Produces `work/artifacts/F-002/architect.md` - -### Stage: build (implementer) -- Implementa `UserProfileService` -- Escribe step definitions -- Ejecuta `behave` para verificar - -### Stage: review_gate (reviewer) -- Verifica código coincide con SDD -- Verifica BDD coverage - -### Stage: security_gate (security) -- Check secrets, dependencies -- SAST scan - -### Stage: qa_gate (qa) -- Ejecuta BDD scenarios -- Verifica trazabilidad - -### Stage: close (leader) -- Verifica todos los gates en verde -- Produce `leader-close.json` - ---- - -## 📁 Archivos generados - -``` -spec/ -├── sdd/ -│ └── components/ -│ └── user-profile-service.md # Componente SDD -│ └── decisions/ -│ └── 002-almacenamiento-avatar.md # ADR (si aplica) -│ -├── bdd/ -│ └── features/ -│ └── profile/ -│ └── user-profile.feature # Feature BDD - -features/ -└── steps/ - └── profile_steps.py # Step definitions -``` - ---- - -## 🚀 Comandos para ejecutar - -```bash -# Verificar estructura -./scripts/verify.sh - -# Ejecutar tests BDD para la feature -behave spec/bdd/features/profile/user-profile.feature - -# Ejecutar solo scenarios con tag -behave spec/bdd/features/profile/user-profile.feature --tags @smoke -``` - ---- - -## Checklist - -- [ ] SDD component creado en `spec/sdd/components/` -- [ ] BDD feature creado en `spec/bdd/features//` -- [ ] Steps implementados en `features/steps/` -- [ ] Todos los scenarios tienen Given/When/Then -- [ ] Tags `@F-XXX` presentes en feature -- [ ] SDD/BDD linkeados en artefacto architect \ No newline at end of file +## Close rule +Feature can be `done` only if: +- review approved +- security approved +- qa approved +- documenter evidence exists +- publish evidence exists (`publish.json`) +- `./scripts/verify.sh` is green diff --git a/HOWTO.md b/HOWTO.md index 07f4ac6..6a6c46d 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -5,13 +5,15 @@ ```bash mkdir mi-proyecto && cd mi-proyecto git init -# copiar contenido de arnes-fork aquí +# instalar/copiAR ARNES dentro de este repo de proyecto +/path/to/arnes/scripts/install_into_repo.sh . ./scripts/start.sh ./scripts/verify.sh python3 scripts/agent_status.py show ``` Después: +- Mete tu código dentro de `project/` (o indica otra ruta en el wizard). - Edita `backlog/features.json` (`project`, `description`). - Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`). - Empieza el ciclo SDD (una feature a la vez). @@ -20,7 +22,13 @@ Después: ## 2) Proyecto ya empezado (brownfield) -Copia al repo existente solo el core ARNES: +Copia al repo existente solo el core ARNES y coloca el código real en `project/` (o usa otra ruta al lanzar el wizard). Recomendado: + +```bash +/path/to/arnes/scripts/install_into_repo.sh . +``` + +Contenido core: - `harness/` - `spec/` - `backlog/` @@ -47,6 +55,17 @@ Crear ticket nuevo (leader/triager, EN caveman): python3 scripts/new_ticket.py ``` +Tipos soportados: +- `feature` +- `fix` +- `bug` +- `chore` + +Al final del ticket: +```bash +python3 scripts/publish_ticket.py --feature-id F-001 +``` + Modelo por tarea: - Config base en `harness/models.profiles.yml` - Reglas en `harness/policies/model-routing.md` @@ -54,5 +73,6 @@ Modelo por tarea: ## Reglas operativas mínimas - Máximo una feature en `in_progress`. - `done` requiere gates `review/security/qa` aprobados. +- `done` requiere publish final con commit+push del ticket. - Evidencia siempre en `work/artifacts//`. - Si `verify.sh` falla, no se cierra la feature. diff --git a/Makefile b/Makefile index ffe9e16..a450d7e 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,4 @@ -.PHONY: run run-dev test verify start ticket clean - -# Puerto por defecto -PORT?=8000 - -run: - @echo "Arrancando ARNES API en http://localhost:$(PORT)/ui/login.html" - @echo "Credenciales: alice@example.com / SecurePass123!" - python3 -m uvicorn src.main:app --host 0.0.0.0 --port $(PORT) - -run-dev: - @echo "Arrancando en modo desarrollo (auto-reload)..." - python3 -m uvicorn src.main:app --reload --port $(PORT) - -test: - python3 -m unittest discover -s tests +.PHONY: verify start ticket publish install clean help verify: ./scripts/verify.sh @@ -24,22 +9,24 @@ start: ticket: python3 scripts/new_ticket.py +publish: + @test -n "$(FEATURE_ID)" || (echo "Use: make publish FEATURE_ID=F-001" && exit 1) + python3 scripts/publish_ticket.py --feature-id $(FEATURE_ID) + +install: + @test -n "$(TARGET)" || (echo "Use: make install TARGET=/path/to/project-repo" && exit 1) + ./scripts/install_into_repo.sh $(TARGET) + clean: find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true -# Help help: - @echo "ARNES UI API - Comandos disponibles:" + @echo "ARNES template - commands:" @echo "" - @echo " make run - Arrancar servidor (puerto 8000)" - @echo " make run PORT=8080 - Arrancar en puerto específico" - @echo " make run-dev - Arrancar con auto-reload" - @echo " make test - Ejecutar tests unitarios" - @echo " make verify - Verificar harness" - @echo " make start - Wizard de inicio de proyecto" - @echo " make ticket - Crear ticket (EN caveman)" - @echo " make clean - Limpiar cache" - @echo "" - @echo "URLs:" - @echo " http://localhost:8000/ui/login.html" \ No newline at end of file + @echo " make verify - Verify harness core" + @echo " make start - First-run wizard" + @echo " make ticket - Create ticket (EN caveman)" + @echo " make publish FEATURE_ID=.. - Commit and push one ticket" + @echo " make install TARGET=.. - Install ARNES into external repo" + @echo " make clean - Clean cache files" diff --git a/README-UI.md b/README-UI.md deleted file mode 100644 index 82a74aa..0000000 --- a/README-UI.md +++ /dev/null @@ -1,57 +0,0 @@ -# ARN-UI API - -API de autenticación con UI integrada. - -## Instalación - -```bash -pip install -r requirements.txt -``` - -## Arrancar - -```bash -# Modo desarrollo (reload automático) -python3 -m uvicorn src.main:app --reload --port 8000 - -# Modo producción -python3 -m uvicorn src.main:app --host 0.0.0.0 --port 8000 -``` - -## Endpoints - -| Método | Endpoint | Descripción | -|--------|----------|-------------| -| GET | `/` | Redirige a UI de login | -| GET | `/health` | Health check | -| POST | `/api/v1/auth/login` | Login con email/password | -| POST | `/api/v1/auth/logout` | Cerrar sesión | -| POST | `/api/v1/auth/refresh` | Refrescar token | -| GET | `/api/v1/auth/validate` | Validar token | -| GET | `/ui/login.html` | Página de login | -| GET | `/ui/dashboard.html` | Dashboard del usuario | -| GET | `/ui/change-password.html` | Cambiar contraseña | - -## Usuarios de prueba - -| Email | Password | -|-------|----------| -| alice@example.com | SecurePass123! | - -## Variables de entorno - -| Variable | Default | Descripción | -|----------|---------|-------------| -| JWT_SECRET | dev-secret-key-change-in-prod | Clave para firmar JWT | - -## Producción - -Para producción, usar: -```bash -uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -O con Gunicorn: -```bash -gunicorn src.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -``` \ No newline at end of file diff --git a/README.md b/README.md index 3d20bc8..0bc8691 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # ARNES Framework (agnóstico) — Diseño v0.1 Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad. + +Convención recomendada: el código real del proyecto vive dentro de `project/`. +Cada proyecto real debe vivir en **su propio repo git**, distinto del repo fuente de ARNES. Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores. --- @@ -67,6 +70,7 @@ Permitir que agentes implementen features de forma autónoma **sin perder contro 7. `qa_gate` (qa) ✅ 8. `documentation_gate` (documenter) ✅ 9. `close` (leader) +10. `publish` (leader) ✅ **Regla:** no hay `done` si cualquier gate falla. @@ -86,6 +90,7 @@ Cada agente escribe artefactos en disco: - `work/artifacts//security.json` - `work/artifacts//qa.json` - `work/artifacts//leader-close.json` +- `work/artifacts//publish.json` Respuesta de agente siempre: `done -> ` o `blocked -> `. @@ -104,29 +109,38 @@ Respuesta de agente siempre: `done -> ` o `blocked -> `. ```text . +├── project/ # código real del proyecto +│ └── README.md ├── README.md +├── AGENTS.md +├── CHECKPOINTS.md ├── harness/ │ ├── agents.matrix.yml │ ├── workflow.stages.yml +│ ├── models.profiles.yml │ ├── policies/ -│ │ ├── security.md -│ │ ├── quality.md -│ │ └── governance.md │ └── contracts/ -│ ├── handoff.md -│ └── evidence.schema.json ├── spec/ │ ├── product.md │ ├── tech.md -│ └── acceptance.md +│ ├── acceptance.md +│ ├── sdd/ +│ └── bdd/ ├── backlog/ │ └── features.json ├── work/ │ ├── current.md │ ├── history.md +│ ├── runtime-status.json │ └── artifacts/ -└── scripts/ - └── verify.sh +├── scripts/ +│ ├── start.sh +│ ├── new_ticket.py +│ ├── agent_status.py +│ └── verify.sh +├── defaults/ +│ └── flask-skeleton/ +└── platforms/ ``` --- @@ -186,16 +200,44 @@ El núcleo no cambia; solo el adaptador. ## Inicio rápido +- Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo` - Ejecuta wizard: `./scripts/start.sh` - Crear ticket: `python3 scripts/new_ticket.py` +- Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001` - Guía breve: `HOWTO.md` - Starter pack: `starter-pack/README.md` - Adaptación del template: `TEMPLATE.md` +- Layout del repo: `docs/repository-layout.md` +- Referencia de scripts: `docs/scripts-reference.md` - Manual Skeleton (uso + mejoras): `docs/skeleton-manual.md` +## Tipos de tarea / ticket + +`python3 scripts/new_ticket.py` soporta estos tipos: + +- `feature`: nueva capacidad +- `fix`: corrección de comportamiento roto +- `bug`: incidencia reportada o defecto claro +- `chore`: trabajo interno, refactor, setup, mantenimiento + +Además guarda campos estructurados: +- `problem` +- `goal` +- `scope_in` +- `scope_out` +- `priority` +- `risk` +- `acceptance` + +Convención recomendada: +- usar `feature` para trabajo nuevo visible +- usar `fix` o `bug` para reparación +- usar `chore` para cambios internos sin valor funcional directo + ## Próximos pasos sugeridos -1. Definir el backlog inicial del proyecto real. -2. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`). -3. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`. -4. Empezar la primera feature `pending` con pipeline completo. +1. Instalar/copiar ARNES en un repo de proyecto real distinto del repo fuente. +2. Definir el backlog inicial del proyecto real. +3. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`). +4. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`. +5. Empezar la primera feature `pending` con pipeline completo y terminar con commit+push del ticket. diff --git a/TEMPLATE.md b/TEMPLATE.md index 718ffae..0c7a395 100644 --- a/TEMPLATE.md +++ b/TEMPLATE.md @@ -1,6 +1,7 @@ # TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto ## 1) Clonar y renombrar contexto +- Pon el código real dentro de `project/` (o elige otra ruta en `./scripts/start.sh`). - Ajusta `backlog/features.json` (`project`, `description`). - Crea primeras features reales en `features[]`. @@ -8,16 +9,19 @@ - Opcional: crea `AGENTS.local.md` con reglas del dominio. - Opcional: crea `scripts/verify.local.sh` con checks propios del stack. - Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`). +- Usa tipos de ticket consistentes: `feature`, `fix`, `bug`, `chore`. - Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`. ## 3) Flujo estándar +0. Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo` 1. `./scripts/start.sh` (primer uso) 2. `python3 scripts/new_ticket.py` (leader/triager) 3. `python3 scripts/agent_status.py show` 4. Seleccionar 1 feature `pending` y pasarla a `in_progress` 5. Implementar con artefactos en `work/artifacts//` -6. Cerrar solo con gates `review/security/qa` + `documenter` aprobados -7. `python3 scripts/agent_status.py reset` +6. Cerrar con gates `review/security/qa` + `documenter` aprobados +7. Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001` +8. `python3 scripts/agent_status.py reset` ## 4) Contrato de cierre - `status=done` exige: @@ -25,6 +29,7 @@ - `security.json` APPROVED - `qa.json` APPROVED - `leader-close.json` APPROVED + - `publish.json` PUBLISHED - `./scripts/verify.sh` OK ## 5) Principio de template diff --git a/backlog/features.json b/backlog/features.json index a332c94..6b9eb57 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -11,15 +11,32 @@ "in_progress", "blocked", "done" + ], + "valid_types": [ + "feature", + "fix", + "bug", + "chore" ] }, "template_feature_schema": { "id": "F-001", - "title": "Título de la feature", - "description": "Descripción funcional", + "type": "feature", + "title": "Short ticket title", + "problem": "Need change", + "goal": "Make flow better", + "scope_in": [ + "Core flow" + ], + "scope_out": [ + "No redesign" + ], + "priority": "med", + "risk": "low", + "description": "Problem: ... Goal: ... Scope IN: ... Scope OUT: ... Type: ... Priority: ... Risk: ...", "acceptance": [ - "Criterio 1", - "Criterio 2" + "Flow works end to end", + "No break old behavior" ], "status": "pending", "created_at": "YYYY-MM-DD", diff --git a/docs/repository-layout.md b/docs/repository-layout.md new file mode 100644 index 0000000..3abbfd5 --- /dev/null +++ b/docs/repository-layout.md @@ -0,0 +1,38 @@ +# Repository layout + +## Core idea +- ARNES core lives at repository root once installed into a project repo. +- The source repo of ARNES is not the repo where product work should happen. +- Real project code lives in `project/` by default. +- Project-specific rules live in overlays, not in core files. + +## Main directories +- `project/` — real app code +- `backlog/` — ticket list and feature state +- `work/` — runtime state, history, artifacts +- `harness/` — workflow, roles, policies, contracts +- `spec/` — product, tech, acceptance, SDD, BDD source docs +- `features/` — optional executable BDD runner assets +- `scripts/` — start, verify, ticket creation, runtime status +- `platforms/` — platform adapters (pi, opencode) +- `defaults/` — optional starter assets + +## Recommended separation +- Core ARNES should stay generic. +- Domain checks go in `scripts/verify.local.sh`. +- Domain rules go in `AGENTS.local.md`. +- Real code should not be mixed into `harness/`, `work/`, `backlog/`, or `spec/`. + +## Default project shape +```text +project/ +├── README.md +├── templates/ +├── static/ +│ ├── css/ +│ ├── js/ +│ └── images/ +└── ... +``` + +This shape is only a default. The wizard can target another app directory if needed. diff --git a/docs/scripts-reference.md b/docs/scripts-reference.md new file mode 100644 index 0000000..320d06a --- /dev/null +++ b/docs/scripts-reference.md @@ -0,0 +1,66 @@ +# Scripts reference + +## `./scripts/install_into_repo.sh /path/to/project-repo` +Copies ARNES core into a different project repo. + +What it does: +- refuses to install into the ARNES source repo itself +- initializes git repo at target if missing +- copies ARNES core files into target repo + +## `./scripts/start.sh` +Interactive bootstrap wizard. + +What it does: +- asks project metadata +- chooses default app directory (`project/` by default) +- writes `harness/project.config.json` +- creates `scripts/verify.local.sh` +- can seed one bootstrap ticket +- resets runtime status + +## `./scripts/verify.sh` +Core harness verification. + +What it checks: +- required core files exist +- project is inside a git repo +- warns if no git remote exists +- backlog JSON is valid +- only one feature is `in_progress` +- done features have all required artifacts, including publish evidence +- runtime status JSON is valid +- optional local overlay runs if present + +## `python3 scripts/new_ticket.py` +Interactive ticket creator. + +Writes one new backlog entry with: +- `type` +- `title` +- `problem` +- `goal` +- `scope_in` +- `scope_out` +- `priority` +- `risk` +- `acceptance` + +## `python3 scripts/publish_ticket.py --feature-id F-001` +Final publish step for one ticket. + +What it does: +- validates git repo, remote, and git identity +- writes `work/artifacts//publish.json` +- creates one commit for the ticket +- pushes the branch to remote + +## `python3 scripts/agent_status.py` +Runtime status helper. + +Commands: +- `show` +- `set` +- `reset` + +The `set` command validates stage and agent names against harness files. diff --git a/features/README.md b/features/README.md new file mode 100644 index 0000000..08a38e9 --- /dev/null +++ b/features/README.md @@ -0,0 +1,11 @@ +# Executable BDD assets + +This directory is for executable BDD helpers. + +Recommended split: +- `spec/bdd/features/` = source-of-truth scenarios in Gherkin +- `features/steps/` = executable step definitions and runner config +- `features/behave.ini` = Behave runner config + +Keep feature text in `spec/bdd/features/`. +Keep runner-specific code in `features/`. diff --git a/features/behave.ini b/features/behave.ini index 41911f1..c225911 100644 --- a/features/behave.ini +++ b/features/behave.ini @@ -1,10 +1,8 @@ [behave] paths = features/ format = pretty -tags = @F-001 -# Para ejecutar solo smoke tests: +# Examples: +# behave features/ # behave features/ --tags @smoke - -# Para excluir tests lentos: -# behave features/ --tags ~@slow \ No newline at end of file +# behave features/ --tags ~@slow diff --git a/features/steps/.gitkeep b/features/steps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/features/steps/auth_steps.py b/features/steps/auth_steps.py deleted file mode 100644 index 246604a..0000000 --- a/features/steps/auth_steps.py +++ /dev/null @@ -1,198 +0,0 @@ -from behave import given, when, then -from pydantic import BaseModel - - -class User(BaseModel): - email: str - password: str - name: str | None = None - - -class AuthService: - def __init__(self): - self.users_db: dict[str, User] = {} - self.sessions: dict[str, str] = {} - - def register(self, email: str, password: str, name: str = "") -> dict: - if email in self.users_db: - raise ValueError("Email already exists") - - self.users_db[email] = User(email=email, password=password, name=name) - token = f"token_{email}" - self.sessions[token] = email - return {"user_id": email, "token": token} - - def login(self, email: str, password: str) -> dict: - user = self.users_db.get(email) - if not user or user.password != password: - raise ValueError("Invalid credentials") - - token = f"token_{email}" - self.sessions[token] = email - return {"user_id": email, "token": token} - - def logout(self, token: str) -> bool: - if token in self.sessions: - del self.sessions[token] - return True - return False - - def has_active_session(self, token: str) -> bool: - return token in self.sessions - - -# Global service instance for tests -auth_service = AuthService() - - -@given('un usuario registrado con email "{email}" y password "{password}"') -def step_registered_user(context, email, password): - """Crea usuario de prueba en el sistema.""" - try: - auth_service.register(email, password, name="Test User") - except ValueError: - pass # Already exists - - -@given('un usuario no registrado con email "{email}"') -def step_unregistered_user(context, email): - """Verifica que el usuario no existe.""" - if email in auth_service.users_db: - del auth_service.users_db[email] - - -@given('el usuario no tiene sesión activa') -def step_no_active_session(context): - """Limpia cualquier sesión activa.""" - context.token = None - - -@when('el usuario navega a la página de login') -def step_navigate_to_login(context): - """Simula navegación a login.""" - context.page = "login" - - -@when('el usuario ingresa su email "{email}"') -def step_enter_email(context, email): - """Ingresa email en el formulario.""" - context.email_input = email - - -@when('ingresa password "{password}"') -def step_enter_password(context, password): - """Ingresa password.""" - context.password_input = password - - -@when('el usuario ingresa email "{email}"') -def step_ingresa_email(context, email): - """Variante: ingresa email.""" - context.email_input = email - - -@when('ingresa password incorrecta "{password}"') -def step_ingresa_password_incorrecto(context, password): - """Variante: ingresa password incorrecto.""" - context.password_input = password - - -@when('deja el campo de password vacío') -def step_password_vacio(context): - """Campo de password vacío.""" - context.password_input = "" - - -@when('presiona el botón "Iniciar sesión"') -def step_press_login_button(context): - """Intenta hacer login.""" - try: - result = auth_service.login(context.email_input, context.password_input) - context.token = result.get("token") - context.login_success = True - except ValueError as e: - context.error_message = str(e) - context.login_success = False - - -@then('el sistema autentica al usuario') -def step_authenticate(context): - """Verifica que el usuario fue autenticado.""" - assert context.login_success, "Login should succeed" - assert context.token is not None, "Token should be generated" - - -@then('redirige a la página del dashboard') -def step_redirect_dashboard(context): - """Verifica redirección a dashboard.""" - assert context.token is not None, "Should have token for authenticated user" - - -@then('muestra un toast de bienvenida con su nombre') -def step_show_welcome_toast(context): - """Verifica toast de bienvenida.""" - assert context.token is not None, "Should show welcome for authenticated user" - - -@then('el sistema muestra mensaje de error "{expected_message}"') -def step_show_error_message(context, expected_message): - """Verifica mensaje de error específico.""" - assert not context.login_success, "Login should fail" - assert context.error_message == expected_message, f"Expected '{expected_message}', got '{context.error_message}'" - - -@then('el usuario permanece en la página de login') -def step_remains_in_login(context): - """Verifica que permanece en login.""" - assert context.page == "login" or not context.login_success - - -@then('el campo de password está vacío') -def step_password_empty(context): - """Verifica que password se limpió.""" - assert context.password_input == "" - - -@then('el sistema sanitiza el input') -def step_sanitize_input(context): - """Verifica sanitización de input malicioso.""" - # El servicio debe rechazar inyecciones - malicious_email = context.email_input if hasattr(context, 'email_input') else "" - assert "'" not in malicious_email or "@" in malicious_email - - -@then('muestra mensaje de error genérico') -def step_generic_error(context): - """Verifica mensaje de error genérico (no revelar detalles).""" - # Para seguridad, no mostrar si el email existe o no - pass - - -@then('no permite acceso al sistema') -def step_no_access(context): - """Verifica que no hay acceso.""" - assert context.token is None or not context.login_success - - -@when('el usuario hace clic en "¿Olvidaste tu contraseña?"') -def step_click_forgot_password(context): - """Clic en recuperación de password.""" - context.page = "recover_password" - - -@then('el sistema muestra formulario de recuperación') -def step_show_recovery_form(context): - """Verifica que muestra formulario.""" - assert context.page == "recover_password" - - -@then('el sistema envía email de recuperación') -def step_send_recovery_email(context): - """Simula envío de email.""" - context.email_sent = True - - -@then('muestra mensaje "Revisa tu bandeja de entrada"') -def step_show_check_inbox(context): - """Verifica mensaje de email enviado.""" - assert context.email_sent, "Email should be sent" \ No newline at end of file diff --git a/features/steps/password_steps.py b/features/steps/password_steps.py deleted file mode 100644 index 1ec327c..0000000 --- a/features/steps/password_steps.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Step definitions para Change Password BDD tests.""" -from behave import given, when, then -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Literal -import re - - -@dataclass -class User: - """User model for testing.""" - id: str - email: str - password_hash: str - sessions: list[str] = field(default_factory=list) - - -class PasswordValidator: - """Validates password strength requirements.""" - - MIN_LENGTH = 8 - MAX_LENGTH = 128 - - @classmethod - def validate(cls, password: str) -> tuple[bool, str]: - """Validate password strength. Returns (is_valid, error_message).""" - if len(password) < cls.MIN_LENGTH: - return False, "La contraseña debe tener al menos 8 caracteres" - if len(password) > cls.MAX_LENGTH: - return False, "La contraseña debe tener máximo 128 caracteres" - if not re.search(r'[A-Z]', password): - return False, "La contraseña debe contener al menos una mayúscula" - if not re.search(r'[a-z]', password): - return False, "La contraseña debe contener al menos una minúscula" - if not re.search(r'\d', password): - return False, "La contraseña debe contener al menos un número" - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password): - return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - return True, "" - - -class PasswordService: - """Service for password management.""" - - def __init__(self): - self.users: dict[str, User] = {} - self.password_history: dict[str, list[str]] = {} # user_id -> list of hashed passwords - self.rate_limits: dict[str, list[datetime]] = {} # user_id -> list of attempt times - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock data.""" - self.users = { - "user-123": User( - id="user-123", - email="user@example.com", - password_hash="OldPass123!" # In real app: bcrypt hash - ), - "user-456": User( - id="user-456", - email="other@example.com", - password_hash="OtherPass456!" - ) - } - - def _hash_password(self, password: str) -> str: - """Mock bcrypt hash.""" - return password # In real app: bcrypt.hashpw() - - def _verify_password(self, password: str, hashed: str) -> bool: - """Mock password verification.""" - return password == hashed # In real app: bcrypt.checkpw() - - def _is_rate_limited(self, user_id: str) -> bool: - """Check if user is rate limited (5 attempts per hour).""" - if user_id not in self.rate_limits: - return False - - # Clean old attempts - one_hour_ago = datetime.now() - timedelta(hours=1) - self.rate_limits[user_id] = [ - t for t in self.rate_limits[user_id] if t > one_hour_ago - ] - - return len(self.rate_limits[user_id]) >= 5 - - def _record_attempt(self, user_id: str): - """Record a password change attempt.""" - if user_id not in self.rate_limits: - self.rate_limits[user_id] = [] - self.rate_limits[user_id].append(datetime.now()) - - def _is_same_as_history(self, user_id: str, new_password: str) -> bool: - """Check if password was used recently.""" - if user_id not in self.password_history: - return False - - # Check last 3 passwords - recent_passwords = self.password_history[user_id][-3:] - return new_password in recent_passwords - - def change_password( - self, - user_id: str, - current_password: str, - new_password: str, - confirm_password: str, - is_authenticated: bool = True, - is_owner: bool = True - ) -> tuple[bool, int, str | None]: - """ - Change user password. - - Returns: (success, status_code, error_message) - """ - # Check authentication - if not is_authenticated: - return False, 401, "No autorizado" - - # Check authorization - if not is_owner: - return False, 403, "No tienes permiso para modificar esta cuenta" - - # Check rate limit - if self._is_rate_limited(user_id): - return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora" - - # Record attempt - self._record_attempt(user_id) - - # Check user exists - if user_id not in self.users: - return False, 404, "Usuario no encontrado" - - user = self.users[user_id] - - # Validate current password - if not current_password: - return False, 400, "La contraseña actual es requerida" - - if not self._verify_password(current_password, user.password_hash): - return False, 401, "La contraseña actual es incorrecta" - - # Validate passwords match - if new_password != confirm_password: - return False, 400, "Las contraseñas no coinciden" - - # Validate new password strength - is_valid, error = PasswordValidator.validate(new_password) - if not is_valid: - return False, 400, error - - # Check password history - if self._is_same_as_history(user_id, new_password): - return False, 400, "La nueva contraseña no puede ser igual a la anterior" - - # Change password - self.password_history.setdefault(user_id, []).append(new_password) - user.password_hash = self._hash_password(new_password) - - # Invalidate all sessions - user.sessions.clear() - - return True, 200, None - - -# Global service instance -password_service = PasswordService() - - -# ==================== -# GIVEN STEPS -# ==================== - -@given('un usuario autenticado con email "{email}"') -def step_user_authenticated_email(context, email): - """User authenticated with specific email.""" - context.is_authenticated = True - context.is_owner = True - # Find user by email - for uid, user in password_service.users.items(): - if user.email == email: - context.user_id = uid - context.user_email = email - context.current_password = user.password_hash - break - - -@given('un usuario autenticado') -def step_user_authenticated(context): - """User authenticated (generic).""" - context.is_authenticated = True - context.is_owner = True - context.user_id = "user-123" - - -@given('su contraseña actual es "{password}"') -def step_current_password(context, password): - """Set current password.""" - context.current_password_input = password - if hasattr(context, 'user_id') and context.user_id in password_service.users: - password_service.users[context.user_id].password_hash = password - - -@given('un usuario no autenticado') -def step_user_not_authenticated(context): - """User not authenticated.""" - context.is_authenticated = False - - -@given('un usuario con contraseña actual "{password}"') -def step_user_with_password(context, password): - """User with specific current password.""" - context.current_password_input = password - - -@given('historial de contraseñas incluye "{password}"') -def step_password_in_history(context, password): - """Add password to user's history.""" - if hasattr(context, 'user_id'): - password_service.password_history.setdefault(context.user_id, []).append(password) - - -@given('un usuario con sesión expirada') -def step_user_expired_session(context): - """User with expired session.""" - context.is_authenticated = False - context.token_expired = True - - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated_id(context, user_id): - """User authenticated with specific ID.""" - context.is_authenticated = True - context.is_owner = True - context.user_id = user_id - - -@given('ya realizó {count} intentos fallidos en la última hora') -def step_rate_limited_user(context, count): - """User has exceeded rate limit.""" - context.user_id = "user-123" - password_service.rate_limits[context.user_id] = [ - datetime.now() - timedelta(minutes=i) for i in range(int(count)) - ] - - -@given('un usuario con contraseña "{password}"') -def step_user_with_specific_password(context, password): - """User with specific password.""" - context.user_id = "user-123" - password_service.users[context.user_id].password_hash = password - - -# ==================== -# WHEN STEPS -# ==================== - -@when('el usuario solicita cambiar contraseña') -def step_request_change_password(context): - """User requests password change.""" - context.password_change_requested = True - - -@when('ingresa contraseña actual "{password}"') -def step_enter_current_password(context, password): - """Enter current password.""" - context.current_password_input = password - - -@when('intenta cambiar contraseña con actual "{password}"') -def step_try_with_current_password(context, password): - """Try to change password with specific current password.""" - context.current_password_input = password - - -@when('ingresa nueva contraseña "{password}"') -def step_enter_new_password(context, password): - """Enter new password.""" - context.new_password_input = password - - -@when('intenta cambiar contraseña a "{password}"') -def step_try_change_to_password(context, password): - """Try to change to specific password.""" - context.new_password_input = password - context.confirm_password_input = password - - -@when('intenta cambiar contraseña a "{prefix}" repetido {count} veces más "{suffix}"') -def step_try_change_long_password(context, prefix, count, suffix): - """Try to change to very long password.""" - long_password = prefix * (int(count) + 1) + suffix - context.new_password_input = long_password - context.confirm_password_input = long_password - - -@when('confirma nueva contraseña "{password}"') -def step_confirm_password(context, password): - """Confirm new password.""" - context.confirm_password_input = password - - -@when('ingresa contraseña actual correcta') -def step_enter_correct_current_password(context): - """Enter correct current password.""" - if hasattr(context, 'current_password'): - context.current_password_input = context.current_password - - -@when('pero confirma con "{password}"') -def step_confirm_different_password(context, password): - """Confirm with different password.""" - context.confirm_password_input = password - - -@when('luego intenta iniciar sesión con "{password}"') -def step_login_with_password(context, password): - """Try to login with new password.""" - context.login_password = password - context.login_user_id = context.user_id - - -@when('intenta cambiar contraseña') -def step_try_change_password(context): - """Try to change password (generic).""" - pass # Will be handled in then step - - -@when('intenta cambiar contraseña del usuario "{target_user_id}"') -def step_try_change_other_user_password(context, target_user_id): - """Try to change another user's password.""" - context.user_id = target_user_id - context.is_owner = False - - -@when('intenta cambiar contraseña una vez más') -def step_try_one_more_time(context): - """Try one more time (rate limited).""" - pass - - -# ==================== -# THEN STEPS -# ==================== - -@then('el sistema valida la contraseña actual correctamente') -def step_validate_current_password(context): - """Validate current password correctly.""" - if hasattr(context, 'new_password_input'): - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input, - context.confirm_password_input or context.new_password_input, - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - -@then('guarda la nueva contraseña hasheada') -def step_save_hashed_password(context): - """Save new hashed password.""" - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user: - # Password was changed, verify it's different - assert user.password_hash != context.current_password_input - - -@then('invalida todas las sesiones existentes') -def step_invalidate_sessions(context): - """Invalidate all user sessions.""" - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user: - assert len(user.sessions) == 0, "Sessions should be cleared" - - -@then('muestra mensaje de confirmación "{message}"') -def step_show_confirmation_message(context, message): - """Show confirmation message.""" - assert context.password_change_success, f"Password change should succeed" - assert context.response_status == 200 - - -@then('el sistema acepta la contraseña') -def step_accept_password(context): - """System accepts the password.""" - if not hasattr(context, 'new_password_input'): - return - - is_valid, error = PasswordValidator.validate(context.new_password_input) - assert is_valid, f"Password should be valid but got: {error}" - - -@then('la guarda correctamente') -def step_save_correctly(context): - """Save password correctly.""" - pass # Handled by previous steps - - -@then('el sistema muestra error "{error_message}"') -def step_show_error(context, error_message): - """Show specific error message.""" - # Execute the change to get the error - if hasattr(context, 'new_password_input'): - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input, - context.confirm_password_input or context.new_password_input, - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - if context.response_error: - # Check if error contains expected message - assert error_message in context.response_error or context.response_status >= 400 - - -@then('la contraseña no es cambiada') -def step_password_not_changed(context): - """Password is not changed.""" - # Verify password wasn't changed - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user and hasattr(context, 'current_password_input'): - # Password should remain unchanged - pass - - -@then('no se invalidan sesiones') -def step_sessions_not_invalidated(context): - """Sessions are not invalidated.""" - pass # If change failed, sessions should remain - - -@then('el sistema retorna error {status_code} "{error_message}"') -def step_return_http_error(context, status_code, error_message): - """Return HTTP error with specific status code.""" - # Execute the change - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input or "TestPass123!", - context.confirm_password_input or "TestPass123!", - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - assert status == int(status_code), f"Expected {status_code}, got {status}" - - -@then('el login es exitoso') -def step_login_successful(context): - """Login is successful with new password.""" - if hasattr(context, 'user_id') and hasattr(context, 'login_password'): - user = password_service.users.get(context.user_id) - if user: - assert user.password_hash == context.login_password, "New password should work" \ No newline at end of file diff --git a/features/steps/profile_steps.py b/features/steps/profile_steps.py deleted file mode 100644 index ffc9887..0000000 --- a/features/steps/profile_steps.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Step definitions para User Profile BDD tests.""" -from behave import given, when, then -from dataclasses import dataclass -from datetime import datetime -import re - - -@dataclass -class UserProfile: - """Modelo de perfil de usuario.""" - id: str - name: str - avatar_url: str - language: str - created_at: datetime = datetime.now() - updated_at: datetime = datetime.now() - - -class ProfileService: - """Servicio mock para tests BDD.""" - - def __init__(self): - self.profiles: dict[str, UserProfile] = {} - self._init_mock_data() - - def _init_mock_data(self): - """Datos mock para testing.""" - self.profiles = { - "user-123": UserProfile( - id="user-123", - name="Juan Pérez", - avatar_url="https://cdn.example.com/avatar-123.jpg", - language="es" - ), - "user-456": UserProfile( - id="user-456", - name="María García", - avatar_url="https://cdn.example.com/avatar-456.jpg", - language="en" - ) - } - - def get_profile(self, user_id: str, authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]: - """Obtiene perfil de usuario.""" - if not authenticated: - return None, 401, "No autorizado" - - if user_id not in self.profiles: - return None, 404, "Usuario no encontrado" - - return self.profiles[user_id], 200, None - - def update_profile(self, user_id: str, name: str | None = None, - avatar_url: str | None = None, language: str | None = None, - authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]: - """Actualiza perfil de usuario.""" - if not authenticated: - return None, 401, "No autorizado" - - if not is_owner: - return None, 403, "No tienes permiso para editar este perfil" - - if user_id not in self.profiles: - return None, 404, "Usuario no encontrado" - - profile = self.profiles[user_id] - - # Validaciones - if name is not None: - if len(name) < 2: - return None, 400, "Nombre debe tener al menos 2 caracteres" - if len(name) > 50: - return None, 400, "Nombre debe tener máximo 50 caracteres" - if not re.match(r'^[a-zA-ZáéíóúñÑ\s]+$', name): - return None, 400, "Nombre inválido: solo letras y espacios" - profile.name = name - - if avatar_url is not None: - if not avatar_url.startswith(('http://', 'https://')): - return None, 400, "Solo se permiten URLs http o https" - if not self._is_valid_url(avatar_url): - return None, 400, "URL de avatar inválida" - profile.avatar_url = avatar_url - - if language is not None: - valid_languages = ['en', 'es', 'fr', 'de'] - if language not in valid_languages: - return None, 400, "Idioma no soportado" - profile.language = language - - profile.updated_at = datetime.now() - return profile, 200, None - - def _is_valid_url(self, url: str) -> bool: - """Valida formato de URL.""" - pattern = r'^https?://[\w\-\.]+\.[a-zA-Z]{2,}(\/[\w\-\./]*)?$' - return bool(re.match(pattern, url)) - - -# Global service instance -profile_service = ProfileService() - - -# ==================== -# GIVEN STEPS -# ==================== - -@given('un usuario autenticado con ID "{user_id}" y nombre "{name}"') -def step_user_authenticated_with_name(context, user_id, name): - """Usuario autenticado con nombre específico.""" - context.user_id = user_id - context.auth_token = f"token_{user_id}" - context.is_authenticated = True - context.is_owner = True - # Ensure user exists in mock - if user_id not in profile_service.profiles: - profile_service.profiles[user_id] = UserProfile(id=user_id, name=name, avatar_url="", language="en") - - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated(context, user_id): - """Usuario autenticado genérico.""" - context.user_id = user_id - context.auth_token = f"token_{user_id}" - context.is_authenticated = True - context.is_owner = True - - -@given('un usuario autenticado') -def step_user_authenticated_generic(context): - """Usuario autenticado sin ID específico.""" - context.is_authenticated = True - context.is_owner = True - context.user_id = "user-123" - - -@given('el usuario tiene avatar "{avatar_url}"') -def step_user_has_avatar(context, avatar_url): - """Usuario tiene avatar específico.""" - context.expected_avatar = avatar_url - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].avatar_url = avatar_url - - -@given('el idioma configurado es "{language}"') -def step_user_language(context, language): - """Idioma del usuario.""" - context.expected_language = language - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].language = language - - -@given('el perfil tiene nombre "{name}"') -def step_profile_has_name(context, name): - """El perfil tiene un nombre específico.""" - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].name = name - - -@given('un usuario no autenticado') -def step_user_not_authenticated(context): - """Usuario sin autenticación.""" - context.is_authenticated = False - - -@given('un usuario con idioma "{language}"') -def step_user_with_language(context, language): - """Usuario con idioma específico.""" - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].language = language - - -@given('un usuario con token expirado') -def step_user_expired_token(context): - """Usuario con token expirado.""" - context.is_authenticated = False - context.token_expired = True - - -# ==================== -# WHEN STEPS -# ==================== - -@when('el usuario solicita ver su perfil') -def step_request_profile(context): - """Solicita ver el perfil.""" - profile, status, error = profile_service.get_profile( - context.user_id, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('el usuario actualiza su nombre a "{new_name}"') -def step_update_name(context, new_name): - """Actualiza el nombre del perfil.""" - profile, status, error = profile_service.update_profile( - context.user_id, - name=new_name, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('el usuario intenta cambiar nombre a "{name}"') -def step_try_update_name(context, name): - """Intenta cambiar nombre (puede fallar).""" - step_update_name(context, name) - - -@when('cambia su nombre a "{name}"') -def step_change_name(context, name): - """Cambia nombre (contexto genérico).""" - step_update_name(context, name) - - -@when('intenta cambiar nombre a "{name}" repetido {times} veces') -def step_update_long_name(context, name, times): - """Nombre muy largo.""" - long_name = name * (int(times) + 1) - step_update_name(context, long_name) - - -@when('el usuario sube un nuevo avatar "{avatar_url}"') -def step_update_avatar(context, avatar_url): - """Sube nuevo avatar.""" - profile, status, error = profile_service.update_profile( - context.user_id, - avatar_url=avatar_url, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('intenta cambiar avatar a "{avatar_url}"') -def step_try_update_avatar(context, avatar_url): - """Intenta cambiar avatar.""" - step_update_avatar(context, avatar_url) - - -@when('el usuario cambia idioma a "{language}"') -def step_change_language(context, language): - """Cambia el idioma.""" - profile, status, error = profile_service.update_profile( - context.user_id, - language=language, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('cambia idioma a "{language}"') -def step_change_lang(context, language): - """Alias para cambiar idioma.""" - step_change_language(context, language) - - -@when('intenta cambiar idioma a "{language}"') -def step_try_change_language(context, language): - """Intenta cambiar idioma.""" - step_change_language(context, language) - - -@when('el usuario solo actualiza nombre a "{new_name}"') -def step_update_only_name(context, new_name): - """Actualiza solo el nombre.""" - step_update_name(context, new_name) - - -@when('envía actualización con nombre "{name}", avatar "{avatar}", idioma "{language}"') -def step_update_multiple_fields(context, name, avatar, language): - """Actualiza múltiples campos.""" - profile, status, error = profile_service.update_profile( - context.user_id, - name=name, - avatar_url=avatar, - language=language, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('intenta actualizar perfil de usuario "{target_user_id}"') -def step_try_update_other_user(context, target_user_id): - """Intenta editar perfil de otro usuario.""" - context.user_id = target_user_id - context.is_owner = False - step_request_profile(context) - - -@when('intenta actualizar su perfil') -def step_try_update_own_profile(context): - """Intenta actualizar su propio perfil.""" - profile, status, error = profile_service.update_profile( - context.user_id, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - - -# ==================== -# THEN STEPS -# ==================== - -@then('el sistema retorna los datos completos del perfil') -def step_return_profile_data(context): - """Verifica que retorna datos del perfil.""" - assert context.profile is not None, "Profile should not be None" - assert context.response_status == 200, f"Expected 200, got {context.response_status}" - - -@then('incluye id "{expected_id}", nombre "{expected_name}"') -def step_profile_contains_id_name(context, expected_id, expected_name): - """Verifica ID y nombre en respuesta.""" - assert context.profile.id == expected_id, f"Expected id {expected_id}, got {context.profile.id}" - assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}" - - -@then('incluye avatar_url y language "{expected_lang}"') -def step_profile_contains_avatar_lang(context, expected_lang): - """Verifica avatar y lenguaje.""" - assert context.profile.avatar_url, "Avatar URL should be present" - assert context.profile.language == expected_lang, f"Expected language {expected_lang}" - - -@then('el sistema retorna error {status_code} "{error_message}"') -def step_return_error(context, status_code, error_message): - """Verifica error específico.""" - status_code = int(status_code) - assert context.response_status == status_code, f"Expected {status_code}, got {context.response_status}" - assert context.response_error == error_message, f"Expected '{error_message}', got '{context.response_error}' - - -@then('el perfil muestra nombre "{expected_name}"') -def step_profile_shows_name(context, expected_name): - """Verifica nombre en perfil.""" - assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}" - - -@then('la fecha de updated_at se actualiza') -def step_updated_at_changed(context): - """Verifica que updated_at cambió (simplificado para test).""" - # En test real verificaríamos timestamp diferente - assert context.profile is not None - - -@then('el sistema acepta el cambio') -def step_accept_change(context): - """Verifica que el cambio fue aceptado.""" - assert context.response_status == 200, f"Expected 200, got {context.response_status}" - - -@then('el nombre se guarda como "{expected_name}"') -def step_name_saved(context, expected_name): - """Verifica nombre guardado.""" - assert context.profile.name == expected_name - - -@then('el sistema muestra error de validación "{error_message}"') -def step_validation_error(context, error_message): - """Verifica error de validación.""" - assert context.response_status == 400, f"Expected 400, got {context.response_status}" - assert context.response_error == error_message or "Nombre inválido" in context.response_error - - -@then('el nombre permanece sin cambios') -def step_name_unchanged(context): - """Verifica que el nombre no cambió.""" - # En tests reales compararíamos con valor original - assert context.profile is not None or context.response_status == 400 - - -@then('el sistema muestra error "{error_message}"') -def step_show_error(context, error_message): - """Verifica mensaje de error genérico.""" - # Acepta cualquier mensaje de error que contenga el texto esperado - assert context.response_error is not None or context.response_status >= 400 - - -@then('el perfil muestra avatar_url "{expected_url}"') -def step_avatar_updated(context, expected_url): - """Verifica nuevo avatar.""" - assert context.profile.avatar_url == expected_url - - -@then('el avatar_url permanece "{expected_url}"') -def step_avatar_unchanged(context, expected_url): - """Verifica que avatar no cambió.""" - assert context.profile.avatar_url == expected_url - - -@then('el idioma se guarda como "{expected_lang}"') -def step_language_saved(context, expected_lang): - """Verifica idioma guardado.""" - assert context.profile.language == expected_lang - - -@then('el sistema confirma el cambio') -def step_confirm_change(context): - """Confirma que el cambio fue exitoso.""" - assert context.response_status == 200 - - -@then('todos los campos se actualizan correctamente') -def step_all_fields_updated(context): - """Verifica actualización múltiple.""" - assert context.response_status == 200 - assert context.profile is not None - - -@then('el perfil refleja todos los cambios') -def step_profile_reflects_changes(context): - """Verifica que todos los cambios están en el perfil.""" - assert context.profile is not None \ No newline at end of file diff --git a/harness/agents.matrix.yml b/harness/agents.matrix.yml index 0131331..9d2c9ec 100644 --- a/harness/agents.matrix.yml +++ b/harness/agents.matrix.yml @@ -4,18 +4,19 @@ roles: leader: emoji: "🧭" can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"] - cannot_edit: ["src/", "tests/"] + cannot_edit: ["project/", "tests/"] responsibilities: - plan - orchestrate - enforce_gates + - publish_ticket_changes - close_feature - issue_orders_in_english_caveman triager: emoji: "🧩" can_edit: ["backlog/", "work/artifacts/", "spec/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status=done"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status=done"] responsibilities: - normalize_requests - create_tickets_in_english_caveman @@ -24,20 +25,21 @@ roles: architect: emoji: "🏗️" can_edit: ["spec/", "harness/contracts/", "docs/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status"] responsibilities: - design - update_contracts implementer: emoji: "🛠️" - can_edit: ["src/", "tests/", "work/artifacts/"] + can_edit: ["project/", "tests/", "work/artifacts/"] cannot_edit: - "backlog/features.json:done" - "work/history.md" - "work/artifacts/*/reviewer.json" - "work/artifacts/*/security.json" - "work/artifacts/*/qa.json" + - "work/artifacts/*/publish.json" - "work/artifacts/*/leader-close.json" responsibilities: - implement_feature @@ -47,7 +49,7 @@ roles: reviewer: emoji: "🔍" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - technical_review - emit_reviewer_verdict @@ -55,7 +57,7 @@ roles: security: emoji: "🔒" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - sast - dependency_review @@ -65,7 +67,7 @@ roles: qa: emoji: "🧪" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - acceptance_traceability - integration_e2e_checks @@ -75,7 +77,7 @@ roles: documenter: emoji: "📚" can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status"] responsibilities: - document_feature_changes - update_user_docs diff --git a/harness/workflow.stages.yml b/harness/workflow.stages.yml index 3f90eb6..954b2b2 100644 --- a/harness/workflow.stages.yml +++ b/harness/workflow.stages.yml @@ -63,9 +63,16 @@ stages: - work/artifacts//leader-close.json - work/history.md + - name: publish + owner: leader + required: true + output: + - work/artifacts//publish.json + close_requirements: - reviewer.json.verdict == "APPROVED" - security.json.verdict == "APPROVED" - qa.json.verdict == "APPROVED" - documenter.md exists + - publish.json.verdict == "PUBLISHED" - scripts/verify.sh exit_code == 0 diff --git a/project/README.md b/project/README.md new file mode 100644 index 0000000..b010e45 --- /dev/null +++ b/project/README.md @@ -0,0 +1,11 @@ +# Project code lives here + +Put the real project code inside this directory. + +Examples: +- `project/app.py` +- `project/templates/` +- `project/static/` +- `project/tests/` (optional, if you want local tests here) + +ARNES core stays outside this folder. diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 99b1135..0000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["pytest:."], - "testpaths": ["tests"], - "pythonpath": ["."], - "addopts": "-v" -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2cadbf3..113b0ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,2 @@ -fastapi>=0.100.0 -uvicorn>=0.23.0 -pydantic>=2.0.0 -pytest>=7.0.0 -httpx>=0.24.0 -PyJWT>=2.8.0 -bcrypt>=4.0.0 \ No newline at end of file +# Template core has no hard runtime deps. +# Add project-specific dependencies after running ./scripts/start.sh diff --git a/scripts/agent_status.py b/scripts/agent_status.py index 24586d9..a5a24a0 100755 --- a/scripts/agent_status.py +++ b/scripts/agent_status.py @@ -8,7 +8,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] STATUS_PATH = ROOT / 'work' / 'runtime-status.json' MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml' +WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml' ARTIFACTS_DIR = ROOT / 'work' / 'artifacts' +VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'} DEFAULT_EMOJIS = { 'leader': '🧭', @@ -26,6 +28,7 @@ GATE_FILES = { 'security': 'security.json', 'qa': 'qa.json', 'documenter': 'documenter.md', + 'publish': 'publish.json', 'leader': 'leader-close.json', } @@ -60,6 +63,28 @@ def load_role_emojis(): return emojis +def load_roles(): + roles = [] + if not MATRIX_PATH.exists(): + return roles + for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines(): + match_role = re.match(r'^ ([a-z_]+):\s*$', line) + if match_role: + roles.append(match_role.group(1)) + return roles + + +def load_stage_names(): + stages = [] + if not WORKFLOW_PATH.exists(): + return stages + for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines(): + match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line) + if match_stage: + stages.append(match_stage.group(1)) + return stages + + def default_status(): return { 'feature_id': None, @@ -99,7 +124,8 @@ def gate_status(feature_id): continue try: payload = json.loads(path.read_text(encoding='utf-8')) - gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present' + wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED' + gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present' except Exception: gates[gate] = 'invalid' return gates @@ -115,10 +141,25 @@ def render_gate(gate, state, emojis): label = { 'leader': 'close', 'documenter': 'docs', + 'publish': 'publish', }.get(gate, gate) return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}" +def validate_runtime_args(args): + roles = set(load_roles()) or set(DEFAULT_EMOJIS) + stages = set(load_stage_names()) | {'idle'} + + if args.agent is not None and args.agent not in roles: + raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}") + if args.next_agent is not None and args.next_agent not in roles: + raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}") + if args.stage is not None and args.stage not in stages: + raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}") + if args.state is not None and args.state not in VALID_RUNTIME_STATES: + raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}") + + def show_status(): status = load_status() emojis = load_role_emojis() @@ -141,7 +182,7 @@ def show_status(): print() print('Gates') if gates: - for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']: + for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']: print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}") else: print(' — Sin feature activa —') @@ -162,6 +203,7 @@ def show_status(): def set_status(args): + validate_runtime_args(args) status = load_status() if args.feature_id is not None: status['feature_id'] = args.feature_id or None diff --git a/scripts/install_into_repo.sh b/scripts/install_into_repo.sh new file mode 100755 index 0000000..d173619 --- /dev/null +++ b/scripts/install_into_repo.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_INPUT="${1:-}" + +if [ -z "$TARGET_INPUT" ]; then + echo "Usage: ./scripts/install_into_repo.sh /path/to/target-repo" + exit 1 +fi + +mkdir -p "$TARGET_INPUT" +TARGET_ROOT="$(cd "$TARGET_INPUT" && pwd)" + +if [ "$TARGET_ROOT" = "$SRC_ROOT" ]; then + echo "Refusing to install ARNES into its own source repository. Use a different project repo." + exit 1 +fi + +if ! git -C "$TARGET_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "No git repo detected at target. Initializing git repository..." + git -C "$TARGET_ROOT" init >/dev/null +fi + +copy_item() { + local item="$1" + if [ -d "$SRC_ROOT/$item" ]; then + mkdir -p "$TARGET_ROOT/$item" + cp -R "$SRC_ROOT/$item"/. "$TARGET_ROOT/$item"/ + else + cp "$SRC_ROOT/$item" "$TARGET_ROOT/$item" + fi +} + +ITEMS=( + AGENTS.md + AGENTS.local.md.example + CHECKPOINTS.md + HOWTO.md + HOWTO-FEATURE.md + README.md + TEMPLATE.md + Makefile + requirements.txt + backlog + defaults + docs + features + harness + platforms + project + scripts + spec + starter-pack + work +) + +for item in "${ITEMS[@]}"; do + copy_item "$item" +done + +echo "Installed ARNES core into: $TARGET_ROOT" +echo "Next steps:" +echo " cd $TARGET_ROOT" +echo " ./scripts/start.sh" +echo " ./scripts/verify.sh" diff --git a/scripts/new_ticket.py b/scripts/new_ticket.py index c50d71c..bd3417f 100755 --- a/scripts/new_ticket.py +++ b/scripts/new_ticket.py @@ -5,6 +5,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BACKLOG = ROOT / 'backlog' / 'features.json' +TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore') +LEVEL_CHOICES = ('low', 'med', 'high') def ask(prompt, default=''): @@ -12,10 +14,23 @@ def ask(prompt, default=''): return value if value else default +def ask_choice(prompt, choices, default): + while True: + value = ask(prompt, default).lower() + if value in choices: + return value + print(f"Invalid value. Use one of: {', '.join(choices)}") + + +def ask_list(prompt, default_csv=''): + raw = ask(prompt, default_csv) + return [item.strip() for item in raw.split(',') if item.strip()] + + def next_id(features): nums = [] - for f in features: - fid = str(f.get('id', '')) + for feature in features: + fid = str(feature.get('id', '')) if fid.startswith('F-') and fid[2:].isdigit(): nums.append(int(fid[2:])) return f"F-{(max(nums) + 1) if nums else 1:03d}" @@ -26,14 +41,14 @@ def main(): features = data.get('features', []) print('Create ticket (English caveman style).') - ttype = ask('Type (feature/fix/bug/chore)', 'feature') - title = ask('Title (short EN)', f'{ttype.capitalize()} TODO') + ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature') + title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO') problem = ask('Problem (short EN)', 'Need change') goal = ask('Goal (short EN)', 'Make flow better') - scope_in = ask('Scope IN (comma list EN)', 'Core flow') - scope_out = ask('Scope OUT (comma list EN)', 'No redesign') - risk = ask('Risk (low/med/high)', 'low') - priority = ask('Priority (low/med/high)', 'med') + scope_in = ask_list('Scope IN (comma list EN)', 'Core flow') + scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign') + risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low') + priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med') print('Acceptance bullets (EN caveman). Empty line to end.') acceptance = [] @@ -47,29 +62,38 @@ def main(): acceptance = [ 'Flow works end to end', 'No break old behavior', - 'verify.sh is green' + 'verify.sh is green', ] fid = next_id(features) desc = ( f"Problem: {problem}. " f"Goal: {goal}. " - f"Scope IN: {scope_in}. " - f"Scope OUT: {scope_out}. " - f"Type: {ttype}. Priority: {priority}. Risk: {risk}." + f"Scope IN: {', '.join(scope_in) or 'none'}. " + f"Scope OUT: {', '.join(scope_out) or 'none'}. " + f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}." ) features.append({ 'id': fid, + 'type': ticket_type, 'title': title, + 'problem': problem, + 'goal': goal, + 'scope_in': scope_in, + 'scope_out': scope_out, + 'priority': priority, + 'risk': risk, 'description': desc, 'acceptance': acceptance, 'status': 'pending', 'created_at': str(date.today()), - 'gates': {'review': False, 'security': False, 'qa': False} + 'gates': {'review': False, 'security': False, 'qa': False}, }) data['features'] = features + rules = data.setdefault('rules', {}) + rules.setdefault('valid_types', list(TYPE_CHOICES)) BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') print(f'Created {fid}: {title}') diff --git a/scripts/publish_ticket.py b/scripts/publish_ticket.py new file mode 100755 index 0000000..f662107 --- /dev/null +++ b/scripts/publish_ticket.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BACKLOG = ROOT / 'backlog' / 'features.json' +ARTIFACTS = ROOT / 'work' / 'artifacts' + + +def now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def run_git(*args, check=True): + result = subprocess.run( + ['git', *args], + cwd=ROOT, + text=True, + capture_output=True, + ) + if check and result.returncode != 0: + msg = result.stderr.strip() or result.stdout.strip() or f'git command failed: {args}' + raise SystemExit(msg) + return result + + +def ensure_git_repo(): + result = run_git('rev-parse', '--is-inside-work-tree', check=False) + if result.returncode != 0 or result.stdout.strip() != 'true': + raise SystemExit('This project is not inside a git repository. Run git init or clone a repo first.') + + +def load_backlog(): + return json.loads(BACKLOG.read_text(encoding='utf-8')) + + +def find_feature(feature_id): + data = load_backlog() + for feature in data.get('features', []): + if str(feature.get('id')) == feature_id: + return feature + raise SystemExit(f'Feature not found in backlog: {feature_id}') + + +def current_branch(): + branch = run_git('symbolic-ref', '--quiet', '--short', 'HEAD', check=False).stdout.strip() + if not branch: + branch = run_git('rev-parse', '--abbrev-ref', 'HEAD', check=False).stdout.strip() + if not branch or branch == 'HEAD': + raise SystemExit('Detached HEAD is not supported for publish. Checkout a branch first.') + return branch + + +def ensure_remote(remote): + remotes = [line.strip() for line in run_git('remote').stdout.splitlines() if line.strip()] + if remote not in remotes: + raise SystemExit(f'Remote not found: {remote}. Add it first with git remote add {remote} .') + + +def status_porcelain(): + return run_git('status', '--porcelain').stdout.strip() + + +def default_commit_message(feature): + feature_id = feature['id'] + ticket_type = feature.get('type') + title = str(feature.get('title', '')).strip() + if ticket_type: + return f'{feature_id} {ticket_type}: {title}' + return f'{feature_id}: {title}' + + +def write_publish_artifact(feature_id, payload): + feature_dir = ARTIFACTS / feature_id + feature_dir.mkdir(parents=True, exist_ok=True) + path = feature_dir / 'publish.json' + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + return path + + +def ensure_git_identity(): + name = run_git('config', 'user.name', check=False).stdout.strip() + email = run_git('config', 'user.email', check=False).stdout.strip() + if not name or not email: + raise SystemExit('Missing git identity. Configure git user.name and user.email before publish.') + + +def main(): + parser = argparse.ArgumentParser(description='Commit and push one ticket, then write publish.json evidence.') + parser.add_argument('--feature-id', required=True) + parser.add_argument('--remote', default='origin') + parser.add_argument('--branch', default='') + parser.add_argument('--commit-message', default='') + args = parser.parse_args() + + ensure_git_repo() + ensure_git_identity() + feature = find_feature(args.feature_id) + remote = args.remote.strip() or 'origin' + branch = args.branch.strip() or current_branch() + ensure_remote(remote) + + if not status_porcelain(): + raise SystemExit('No git changes to publish. Nothing to commit.') + + commit_message = args.commit_message.strip() or default_commit_message(feature) + payload = { + 'agent': 'leader', + 'verdict': 'PUBLISHED', + 'feature_id': args.feature_id, + 'branch': branch, + 'remote': remote, + 'message': commit_message, + 'pushed': True, + 'published_at': now_iso(), + 'note': 'This artifact is committed inside the publish commit for this ticket.' + } + artifact_path = write_publish_artifact(args.feature_id, payload) + + run_git('add', '-A') + if not status_porcelain(): + raise SystemExit('No staged git changes after git add -A. Nothing to commit.') + + run_git('commit', '-m', commit_message) + run_git('push', remote, branch) + print(f'done -> {artifact_path}') + + +if __name__ == '__main__': + main() diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index 8ed788d..0000000 --- a/scripts/run.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# Script para arrancar el servidor ARNES UI API - -set -e - -cd "$(dirname "$0")" - -# Configuración -PORT=${1:-8000} -HOST="0.0.0.0" - -# Colores -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN} ARNES API - Starting...${NC}" -echo -e "${GREEN}========================================${NC}" -echo "" -echo -e " URL: ${YELLOW}http://localhost:${PORT}/ui/login.html${NC}" -echo -e " Host: ${YELLOW}${HOST}:${PORT}${NC}" -echo "" -echo -e " Credenciales de prueba:" -echo -e " Email: ${YELLOW}alice@example.com${NC}" -echo -e " Password: ${YELLOW}SecurePass123!${NC}" -echo "" - -# Instalar dependencias si falta -if ! python3 -c "import fastapi" 2>/dev/null; then - echo -e "${YELLOW}Instalando dependencias...${NC}" - pip3 install -q fastapi uvicorn pydantic PyJWT bcrypt httpx -fi - -# Arrancar servidor -exec python3 -m uvicorn src.main:app --host "$HOST" --port "$PORT" --reload \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh index d0b176e..d13b053 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -16,12 +16,16 @@ ask() { } echo "=== ARNES start wizard ===" +echo "Mode: use this template in a new repo or copy core ARNES into an existing repo." -echo "Mode: clone arnes-fork, put your app folder inside, run this wizard." +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "No git repo detected. Initializing local git repository..." + git init >/dev/null +fi PROJECT_NAME="$(ask 'Project name' 'my-project')" PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')" -APP_DIR="$(ask 'App directory (relative)' 'app')" +APP_DIR="$(ask 'App directory (relative)' 'project')" STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')" if [ "$STACK_CHOICE" = "2" ]; then @@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')" ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')" mkdir -p "$APP_DIR" +[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" < harness/project.config.json < work/current.md </dev/null 2>&1; then + ok "Git repo detectado" + if git remote | grep -q .; then + ok "Git remote configurado" + else + warn "Sin git remote configurado (publish requerirá remote)" + fi +else + fail "Este proyecto debe vivir dentro de un git repo" + EXIT_CODE=1 +fi + echo "" echo "── 2) Validando backlog + gates ───────────────────────" python3 - <<'PY' @@ -59,6 +88,7 @@ import sys root = pathlib.Path('.') path = root / 'backlog' / 'features.json' +level_choices = {'low', 'med', 'high'} try: data = json.loads(path.read_text(encoding='utf-8')) @@ -66,7 +96,9 @@ except Exception as e: print(f"[FAIL] backlog/features.json inválido: {e}") sys.exit(1) -valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"])) +rules = data.get('rules', {}) +valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"])) +valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"])) features = data.get('features', []) if not isinstance(features, list): print('[FAIL] features debe ser una lista') @@ -85,25 +117,65 @@ if len(in_progress) > 1: for f in features: fid = str(f.get('id', '')).strip() status = f.get('status') - if status not in valid: + title = str(f.get('title', '')).strip() + acceptance = f.get('acceptance') + gates = f.get('gates', {}) + + if not fid: + print('[FAIL] Hay una feature sin id') + sys.exit(1) + if not title: + print(f"[FAIL] Feature {fid} sin title") + sys.exit(1) + if status not in valid_status: print(f"[FAIL] Estado inválido en feature {fid}: {status}") sys.exit(1) + if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance): + print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía") + sys.exit(1) + + ticket_type = f.get('type') + if ticket_type is not None and ticket_type not in valid_types: + print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}") + sys.exit(1) + + for field in ('priority', 'risk'): + value = f.get(field) + if value is not None and value not in level_choices: + print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}") + sys.exit(1) + + for field in ('scope_in', 'scope_out'): + value = f.get(field) + if value is not None: + if not isinstance(value, list) or any(not str(item).strip() for item in value): + print(f"[FAIL] Feature {fid} tiene {field} inválido") + sys.exit(1) + + if gates: + for gate_name in ('review', 'security', 'qa'): + gate_value = gates.get(gate_name) + if not isinstance(gate_value, bool): + print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido") + sys.exit(1) if status == 'done': d = root / 'work' / 'artifacts' / fid - req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md'] + req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.json'] missing = [name for name in req if not (d / name).is_file()] if missing: print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}") sys.exit(1) expected = { - 'reviewer.json': 'reviewer', - 'security.json': 'security', - 'qa.json': 'qa', - 'leader-close.json': 'leader', + 'reviewer.json': ('reviewer', 'APPROVED'), + 'security.json': ('security', 'APPROVED'), + 'qa.json': ('qa', 'APPROVED'), + 'leader-close.json': ('leader', 'APPROVED'), + 'publish.json': ('leader', 'PUBLISHED'), } - for filename, agent in expected.items(): + for filename, rule in expected.items(): + agent, verdict = rule try: obj = json.loads((d / filename).read_text(encoding='utf-8')) except Exception as e: @@ -113,8 +185,11 @@ for f in features: if obj.get('agent') != agent: print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'") sys.exit(1) - if obj.get('verdict') != 'APPROVED': - print(f"[FAIL] {fid}/{filename} no está APPROVED") + if obj.get('verdict') != verdict: + print(f"[FAIL] {fid}/{filename} no está {verdict}") + sys.exit(1) + if filename == 'publish.json' and obj.get('pushed') is not True: + print(f"[FAIL] {fid}/{filename} debe tener pushed=true") sys.exit(1) print(f"[OK] backlog válido ({len(features)} features)") diff --git a/spec/bdd/README.md b/spec/bdd/README.md index 0e6ce3f..895c87b 100644 --- a/spec/bdd/README.md +++ b/spec/bdd/README.md @@ -10,13 +10,16 @@ ## Overview -Este directorio contiene especificaciones BDD en formato Gherkin. -Los archivos `.feature` sirven como especificación ejecutable. +Este directorio contiene las especificaciones BDD fuente en formato Gherkin. + +Separación recomendada: +- `spec/bdd/features/` = source-of-truth de escenarios +- `features/` = assets ejecutables del runner (steps, config) ### naming conventions -``` -features/ +```text +spec/bdd/features/ ├── / │ ├── .feature │ └── .feature diff --git a/spec/bdd/features/.gitkeep b/spec/bdd/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/bdd/features/README.md b/spec/bdd/features/README.md index 2d09975..16f2ef1 100644 --- a/spec/bdd/features/README.md +++ b/spec/bdd/features/README.md @@ -1,58 +1,12 @@ -# Features BDD +# BDD feature files -Este directorio contiene los archivos `.feature` organizados por dominio. +Put Gherkin `.feature` files here. -## Estructura +Example: +- `spec/bdd/features/checkout/purchase.feature` +- `spec/bdd/features/common/error-handling.feature` -``` -features/ -├── auth/ -│ ├── login.feature -│ └── registration.feature -├── dashboard/ -│ └── dashboard.feature -├── common/ -│ ├── navigation.feature -│ └── error-handling.feature -└── README.md -``` - -## Tags comunes - -Usar estos tags en todos los features: - -| Tag | Descripción | -|-----|-------------| -| `@F-XXX` | Link a feature ID del backlog | -| `@smoke` | Test crítico | -| `@regression` | Regresión | - -## Example - -```gherkin -@F-001 @auth @smoke -Feature: Inicio de sesión - - Como usuario registrado - Quiero iniciar sesión con mis credenciales - Para acceder a mi cuenta personal - - @positive - Scenario: Login exitoso con credenciales válidas - Given un usuario con email "user@example.com" y password "Password123" - And el usuario no tiene sesión activa - When el usuario ingresa email "user@example.com" - And ingresa password "Password123" - And presiona el botón "Iniciar sesión" - Then el sistema redirige al dashboard - And muestra mensaje de bienvenida - - @negative - Scenario: Login fallido con password incorrecto - Given un usuario con email "user@example.com" y password "Password123" - When el usuario ingresa email "user@example.com" - And ingresa password "WrongPassword" - And presiona el botón "Iniciar sesión" - Then el sistema muestra mensaje de error "Credenciales inválidas" - And permanece en la página de login -``` \ No newline at end of file +Use tags like: +- `@F-001` +- `@smoke` +- `@regression` diff --git a/spec/bdd/features/auth/login.feature b/spec/bdd/features/auth/login.feature deleted file mode 100644 index ff45d47..0000000 --- a/spec/bdd/features/auth/login.feature +++ /dev/null @@ -1,70 +0,0 @@ -@F-004 @auth @login -Feature: User Login - - Background: - Given the user "alice@example.com" exists with password "SecurePass123!" - - @positive - Scenario: Successful login with valid credentials - Given I have valid email "alice@example.com" and password "SecurePass123!" - When I attempt to login - Then I should receive an access token - And the access token should contain user_id claim - And the access token should contain email claim - And the access token should not be expired - - @positive - Scenario: Login returns refresh token - Given I have valid credentials for "alice@example.com" - When I login successfully - Then I should receive a refresh token - And the refresh token should be different from access token - And the refresh token should have longer expiration - - @positive - Scenario: Login email is case-insensitive - Given a user exists with email "bob@test.com" and password "TestPass99!" - When I login with email "BOB@TEST.COM" and password "TestPass99!" - Then login should be successful - - @negative - Scenario: Login with wrong password - Given I have email "alice@example.com" and password "WrongPassword123!" - When I attempt to login - Then I should receive error "Credenciales inválidas" - And I should not receive any token - - @negative - Scenario: Login with nonexistent user - Given I have email "nonexistent@test.com" and password "AnyPass123!" - When I attempt to login - Then I should receive error "Credenciales inválidas" - And I should not receive any token - - @negative - Scenario: Login with empty password - Given I have email "alice@example.com" and empty password - When I attempt to login - Then I should receive validation error - And I should not receive any token - - @negative - Scenario: Login with invalid email format - Given I have email "not-an-email" and password "ValidPass123!" - When I attempt to login - Then I should receive validation error - And I should not receive any token - - @security @rate-limit - Scenario: Login blocked after 10 failed attempts - Given I have email "alice@example.com" and password "WrongPassword!" - When I attempt to login 10 times with wrong password - Then account should be temporarily locked - And next login attempt should return error "Cuenta bloqueada" - - @smoke - Scenario: Login endpoint responds with JSON - Given I have valid credentials for "alice@example.com" - When I send a POST request to "/api/v1/auth/login" - Then response should be JSON format - And response should have correct content-type header \ No newline at end of file diff --git a/spec/bdd/features/auth/logout.feature b/spec/bdd/features/auth/logout.feature deleted file mode 100644 index 70977db..0000000 --- a/spec/bdd/features/auth/logout.feature +++ /dev/null @@ -1,58 +0,0 @@ -@F-004 @auth @logout -Feature: User Logout - - Background: - Given the user "alice@example.com" exists with password "SecurePass123!" - And I am authenticated as "alice@example.com" - - @positive - Scenario: Successful logout invalidates current session - Given my current access token is valid - When I logout - Then I should receive confirmation - And my session should be marked as revoked - And my access token should no longer be valid - - @positive - Scenario: Logout with refresh token also invalidates access - Given I have a valid refresh token - When I logout - Then both access and refresh tokens should be invalid - And I should not be able to get new access token with refresh - - @positive - Scenario: Logout all sessions for user - Given I am logged in from device "desktop" - And I am logged in from device "mobile" - When I logout from all devices - Then all my sessions should be invalidated - And I should not be able to use any previous token - - @negative - Scenario: Using token after logout returns unauthorized - Given I previously logged in successfully - And I have logged out - When I try to use my old access token - Then I should receive 401 Unauthorized - And I should not have access to protected resources - - @negative - Scenario: Logout with invalid token does nothing - Given I have an invalid/expired token - When I attempt to logout - Then logout should not fail - But no session should be affected - - @security - Scenario: Concurrent logout requests are handled correctly - Given my session is valid - When I send multiple logout requests simultaneously - Then only one logout operation should occur - And token should be invalidated only once - - @smoke - Scenario: Logout endpoint returns 200 on success - Given I am authenticated as "alice@example.com" - When I send POST request to "/api/v1/auth/logout" - Then response should be 200 OK - And response should indicate success \ No newline at end of file diff --git a/spec/bdd/features/common/README.md b/spec/bdd/features/common/README.md deleted file mode 100644 index c2fad55..0000000 --- a/spec/bdd/features/common/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Common Features - -Features que se reutilizan en múltiples dominios. - -## Navigation - -```gherkin -@common @navigation -Feature: Navegación entre páginas - - Scenario: Navegar a través del menú - Given el usuario está en la página principal - When hace clic en el elemento de menú "Dashboard" - Then la URL cambia a "/dashboard" - And el título de la página muestra "Dashboard" -``` - -## Error Handling - -```gherkin -@common @error-handling -Feature: Manejo de errores - - Scenario: Mostrar error de red - Given la conexión a internet está disponible - And el servidor no responde - When el usuario realiza una acción que requiere red - Then el sistema muestra toast "Error de conexión" - And ofrece opción de reintentar - - Scenario: Timeout de solicitud - Given el usuario tiene sesión activa - When realiza una solicitud que excede 30 segundos - Then el sistema muestra indicador de carga - And después de timeout muestra error "Solicitud expirada" -``` \ No newline at end of file diff --git a/spec/bdd/features/password/change-password.feature b/spec/bdd/features/password/change-password.feature deleted file mode 100644 index b2a68b7..0000000 --- a/spec/bdd/features/password/change-password.feature +++ /dev/null @@ -1,171 +0,0 @@ -@F-003 @password -Feature: Cambio de Contraseña - - Como usuario autenticado - Quiero cambiar mi contraseña - Para mantener mi cuenta segura con credenciales actualizadas - - # ==================== - # HAPPY PATH - # ==================== - - @smoke @positive - Scenario: Cambiar contraseña exitosamente - Given un usuario autenticado con email "user@example.com" - And su contraseña actual es "OldPass123!" - When el usuario solicita cambiar contraseña - And ingresa contraseña actual "OldPass123!" - And ingresa nueva contraseña "NewPass456@" - And confirma nueva contraseña "NewPass456@" - Then el sistema valida la contraseña actual correctamente - And guarda la nueva contraseña hasheada - And invalida todas las sesiones existentes - And muestra mensaje de confirmación "Contraseña actualizada exitosamente" - - @positive - Scenario: Contraseña con todos los caracteres especiales permitidos - Given un usuario autenticado - When cambia contraseña a "!@#$%^&*()_+-=[]{}|;':\",./<>?abc123ABC" - Then el sistema acepta la contraseña - And la guarda correctamente - - # ==================== - # PASSWORD VALIDATION - # ==================== - - @negative - Scenario: Nueva contraseña muy corta (menos de 8 caracteres) - Given un usuario autenticado - When intenta cambiar contraseña a "Ab1!" - Then el sistema muestra error "La contraseña debe tener al menos 8 caracteres" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña muy larga (más de 128 caracteres) - Given un usuario autenticado - When intenta cambiar contraseña a "A" repetido 129 veces más "a1!" - Then el sistema muestra error "La contraseña debe tener máximo 128 caracteres" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin mayúscula - Given un usuario autenticado - When intenta cambiar contraseña a "password123!" - Then el sistema muestra error "La contraseña debe contener al menos una mayúscula" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin minúscula - Given un usuario autenticado - When intenta cambiar contraseña a "PASSWORD123!" - Then el sistema muestra error "La contraseña debe contener al menos una minúscula" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin número - Given un usuario autenticado - When intenta cambiar contraseña a "PasswordABC!" - Then el sistema muestra error "La contraseña debe contener al menos un número" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin carácter especial - Given un usuario autenticado - When intenta cambiar contraseña a "Password123" - Then el sistema muestra error "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - And la contraseña no es cambiada - - # ==================== - # CURRENT PASSWORD - # ==================== - - @negative - Scenario: Contraseña actual incorrecta - Given un usuario autenticado con contraseña actual "CorrectPass123!" - When intenta cambiar contraseña con actual "WrongPass456!" - And nueva contraseña "NewPass789@" - Then el sistema muestra error "La contraseña actual es incorrecta" - And la contraseña no es cambiada - And no se invalidan sesiones - - @negative - Scenario: Contraseña actual vacía - Given un usuario autenticado - When intenta cambiar contraseña con actual "" - And nueva contraseña "NewPass123@" - Then el sistema muestra error "La contraseña actual es requerida" - And la contraseña no es cambiada - - # ==================== - # PASSWORD MISMATCH - # ==================== - - @negative - Scenario: Nueva contraseña y confirmación no coinciden - Given un usuario autenticado - When ingresa contraseña actual correcta - And ingresa nueva contraseña "NewPass123@" - But confirma con "DifferentPass456!" - Then el sistema muestra error "Las contraseñas no coinciden" - And la contraseña no es cambiada - - # ==================== - # REUSE DETECTION - # ==================== - - @negative @security - Scenario: Reutilizar contraseña anterior - Given un usuario autenticado con contraseña actual "MyPass123!" - And historial de contraseñas incluye "MyPass123!" - When intenta cambiar contraseña a "MyPass123!" - Then el sistema muestra error "La nueva contraseña no puede ser igual a la anterior" - And la contraseña no es cambiada - - # ==================== - # AUTHORIZATION - # ==================== - - @negative @security - Scenario: Usuario no autenticado intenta cambiar contraseña - Given un usuario no autenticado - When intenta cambiar contraseña - Then el sistema retorna error 401 "No autorizado" - And la contraseña no es cambiada - - @negative @security - Scenario: Token expirado al cambiar contraseña - Given un usuario con sesión expirada - When intenta cambiar contraseña - Then el sistema retorna error 401 "Sesión expirada" - And la contraseña no es cambiada - - @negative @security - Scenario: Intentar cambiar contraseña de otro usuario - Given un usuario autenticado con ID "user-123" - When intenta cambiar contraseña del usuario "user-456" - Then el sistema retorna error 403 "No tienes permiso para modificar esta cuenta" - And la contraseña no es cambiada - - # ==================== - # RATE LIMITING - # ==================== - - @negative @security - Scenario: Superar límite de intentos (rate limit) - Given un usuario autenticado - And ya realizó 5 intentos fallidos en la última hora - When intenta cambiar contraseña una vez más - Then el sistema retorna error 429 "Demasiados intentos. Intenta de nuevo en 1 hora" - And todas las solicitudes son bloqueadas hasta que pase el tiempo - - # ==================== - # SUCCESSFUL REAUTHENTICATION - # ==================== - - @positive - Scenario: Cambio de contraseña seguido de login exitoso - Given un usuario con contraseña "OldPass123!" - When cambia su contraseña a "NewPass456@" - And luego intenta iniciar sesión con "NewPass456@" - Then el login es exitoso - And el usuario accede a su cuenta \ No newline at end of file diff --git a/spec/bdd/features/profile/user-profile.feature b/spec/bdd/features/profile/user-profile.feature deleted file mode 100644 index 9fea373..0000000 --- a/spec/bdd/features/profile/user-profile.feature +++ /dev/null @@ -1,159 +0,0 @@ -@F-002 @profile -Feature: Gestión de Perfil de Usuario - - Como usuario autenticado - Quiero gestionar mi perfil - Para mantener mis datos personales actualizados y personalizar mi experiencia - - # ==================== - # VIEW PROFILE - # ==================== - - @smoke @positive - Scenario: Ver perfil de usuario exitosamente - Given un usuario autenticado con ID "user-123" y nombre "Juan Pérez" - And el usuario tiene avatar "https://cdn.example.com/avatar-123.jpg" - And el idioma configurado es "es" - When el usuario solicita ver su perfil - Then el sistema retorna los datos completos del perfil - And incluye id "user-123", nombre "Juan Pérez" - And incluye avatar_url y language "es" - - @negative - Scenario: Ver perfil sin autenticación - Given un usuario no autenticado - When el usuario solicita ver su perfil - Then el sistema retorna error 401 "No autorizado" - And no retorna datos del perfil - - @negative - Scenario: Ver perfil de usuario inexistente - Given un usuario autenticado - When solicita ver perfil de ID "nonexistent-user" - Then el sistema retorna error 404 "Usuario no encontrado" - - # ==================== - # UPDATE NAME - # ==================== - - @smoke @positive - Scenario: Editar nombre del perfil exitosamente - Given un usuario autenticado con ID "user-123" - And el perfil tiene nombre "Juan" - When el usuario actualiza su nombre a "Pedro" - Then el perfil muestra nombre "Pedro" - And la fecha de updated_at se actualiza - - @positive - Scenario: Editar nombre con caracteres unicode válidos - Given un usuario autenticado - When cambia su nombre a "José García" - Then el sistema acepta el cambio - And el nombre se guarda como "José García" - - @negative - Scenario: Editar nombre con caracteres inválidos - Given un usuario autenticado - When intenta cambiar nombre a "Juan@123!" - Then el sistema muestra error de validación "Nombre inválido: solo letras y espacios" - And el nombre permanece sin cambios - - @negative - Scenario: Editar nombre con menos de 2 caracteres - Given un usuario autenticado - When intenta cambiar nombre a "J" - Then el sistema muestra error "Nombre debe tener al menos 2 caracteres" - - @negative - Scenario: Editar nombre con más de 50 caracteres - Given un usuario autenticado - When intenta cambiar nombre a "A" repetido 51 veces - Then el sistema muestra error "Nombre debe tener máximo 50 caracteres" - - # ==================== - # UPDATE AVATAR - # ==================== - - @smoke @positive - Scenario: Cambiar avatar exitosamente - Given un usuario autenticado con avatar actual "https://cdn.example.com/old.jpg" - When el usuario sube un nuevo avatar "https://cdn.example.com/new.jpg" - Then el perfil muestra avatar_url "https://cdn.example.com/new.jpg" - - @negative - Scenario: Cambiar avatar con URL inválida - Given un usuario autenticado - When intenta cambiar avatar a "not-a-valid-url" - Then el sistema muestra error "URL de avatar inválida" - And el avatar permanece sin cambios - - @negative - Scenario: Cambiar avatar con URL de protocolo no permitido - Given un usuario autenticado - When intenta cambiar avatar a "ftp://malicious.com/file.jpg" - Then el sistema muestra error "Solo se permiten URLs http o https" - And el avatar permanece sin cambios - - # ==================== - # UPDATE LANGUAGE - # ==================== - - @smoke @positive - Scenario: Cambiar idioma a español exitosamente - Given un usuario autenticado con idioma "en" - When el usuario cambia idioma a "es" - Then el idioma se guarda como "es" - And el sistema confirma el cambio - - @positive - Scenario: Cambiar idioma a francés - Given un usuario autenticado - When cambia idioma a "fr" - Then el sistema acepta "fr" como idioma válido - - @positive - Scenario: Cambiar idioma a alemán - Given un usuario autenticado - When cambia idioma a "de" - Then el sistema acepta "de" como idioma válido - - @negative - Scenario: Cambiar idioma a idioma no soportado - Given un usuario autenticado - When intenta cambiar idioma a "zh" - Then el sistema muestra error "Idioma no soportado" - And el idioma permanece sin cambios - - # ==================== - # PARTIAL UPDATE - # ==================== - - @positive - Scenario: Actualizar solo nombre sin cambiar avatar - Given un usuario autenticado con nombre "Juan" y avatar "https://cdn.com/img.jpg" - When el usuario solo actualiza nombre a "Pedro" - Then el nombre cambia a "Pedro" - And el avatar_url permanece "https://cdn.com/img.jpg" - - @positive - Scenario: Actualizar múltiples campos en una petición - Given un usuario autenticado - When envía actualización con nombre "María", avatar "https://cdn.com/maria.jpg", idioma "es" - Then todos los campos se actualizan correctamente - And el perfil refleja todos los cambios - - # ==================== - # AUTHORIZATION - # ==================== - - @negative @security - Scenario: Usuario intenta editar perfil de otro usuario - Given un usuario autenticado con ID "user-123" - When intenta actualizar perfil de usuario "user-456" - Then el sistema retorna error 403 "No tienes permiso para editar este perfil" - - @negative @security - Scenario: Token expirado al editar perfil - Given un usuario con token expirado - When intenta actualizar su perfil - Then el sistema retorna error 401 "Sesión expirada" \ No newline at end of file diff --git a/spec/sdd-bdd-guide.md b/spec/sdd-bdd-guide.md index fcfed12..f00010b 100644 --- a/spec/sdd-bdd-guide.md +++ b/spec/sdd-bdd-guide.md @@ -1,4 +1,4 @@ -# SDD/BBD Guide — System Design Document & Behavior Driven Development +# SDD/BDD Guide — System Design Document & Behavior Driven Development Guía para crear y mantener SDD (System Design Document) y BDD (Behavior Driven Development) specs dentro del framework ARNES. @@ -25,10 +25,13 @@ spec/ │ ├── architecture.md │ ├── components/ │ └── decisions/ -└── bdd/ # Behavior Driven Development +└── bdd/ # Behavior Driven Development source-of-truth ├── README.md - ├── features/ - └── step_definitions/ + └── features/ + +features/ # optional executable BDD runner assets +├── behave.ini +└── steps/ ``` --- @@ -152,6 +155,11 @@ spec/bdd/features/ │ └── purchase.feature └── common/ └── error-handling.feature + +features/ +├── behave.ini +└── steps/ + └── login_steps.py ``` ### Tags para trazabilidad @@ -224,8 +232,10 @@ Tags disponibles: ```bash # Estructura +spec/bdd/features/ +└── login.feature + features/ -├── login.feature └── steps/ └── login_steps.py @@ -237,8 +247,10 @@ behave features/ ```bash # Estructura +spec/bdd/features/ +└── login.feature + features/ -├── login.feature └── step_definitions/ └── login_steps.js diff --git a/spec/sdd/components/.gitkeep b/spec/sdd/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/sdd/components/.template.md b/spec/sdd/components/.template.md deleted file mode 100644 index 28f0370..0000000 --- a/spec/sdd/components/.template.md +++ /dev/null @@ -1,74 +0,0 @@ -# Component: - -## Responsabilidad -Descripción clara de qué hace este componente. - -## Tipo -- [ ] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API (si aplica) -``` -Method: GET/POST/PUT/DELETE /endpoint -Input: { ... } -Output: { ... } -Errors: 400, 401, 404, 500 -``` - -### Eventos (si aplica) -- `topic.name.v1` — descripción del evento - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| | | | - -## Límites - -### Alcance -- ✅ Qué hace -- ❌ Qué NO hace - -### Constraints -- Timeout máximo: Xms -- Rate limit: Y req/min - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 | < 200ms | - -## Diagrama - -```mermaid -graph LR - A[Input] --> B[Component] - B --> C[Output] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | created | ... | -| Active | running | ... | -| Error | failure | ... | - -## Seguridad - -- Authentication: ... -- Authorization: ... -- Rate limiting: ... - -## Observabilidad - -- Metrics: ... -- Logs: ... -- Traces: ... \ No newline at end of file diff --git a/spec/sdd/components/README.md b/spec/sdd/components/README.md new file mode 100644 index 0000000..2170c2e --- /dev/null +++ b/spec/sdd/components/README.md @@ -0,0 +1,8 @@ +# SDD components + +Put one markdown file per technical component. + +Example: +- `api-gateway.md` +- `order-service.md` +- `cart-repository.md` diff --git a/spec/sdd/components/auth-service.md b/spec/sdd/components/auth-service.md deleted file mode 100644 index 0437b4e..0000000 --- a/spec/sdd/components/auth-service.md +++ /dev/null @@ -1,65 +0,0 @@ -# AuthService Component - -## Purpose -Handle user authentication (login/logout) with JWT tokens. - -## Public API - -### Methods - -#### login(email: str, password: str) -> AuthResult -Authenticate user with email and password. - -**Parameters:** -- `email`: User email address -- `password`: User password - -**Returns:** -- `AuthResult` with access_token, refresh_token, expires_in - -**Raises:** -- `InvalidCredentialsError`: Email or password incorrect -- `AccountLockedError`: Account temporarily locked -- `ValidationError`: Invalid input format - -#### logout(user_id: str, token_id: str) -> bool -Invalidate a specific session/token. - -**Parameters:** -- `user_id`: User ID -- `token_id`: JWT jti (token identifier) - -**Returns:** True if successful - -#### logout_all(user_id: str) -> int -Invalidate all sessions for a user. - -**Parameters:** -- `user_id`: User ID - -**Returns:** Number of sessions invalidated - -#### refresh(refresh_token: str) -> AuthResult -Get new access token from refresh token. - -**Parameters:** -- `refresh_token`: Valid refresh token - -**Returns:** New AuthResult with access_token - -**Raises:** -- `InvalidTokenError`: Token expired or invalid - ---- - -## Dependencies -- `TokenService`: JWT generation/validation -- `SessionStore`: Track active sessions -- `UserRepository`: Fetch user data -- `PasswordService`: Verify password (from F-003) - -## Configuration -```python -LOGIN_RATE_LIMIT = 10 # attempts per window -RATE_LIMIT_WINDOW = 900 # 15 minutes -ACCOUNT_LOCKOUT_DURATION = 1800 # 30 minutes \ No newline at end of file diff --git a/spec/sdd/components/password-service.md b/spec/sdd/components/password-service.md deleted file mode 100644 index fa4a700..0000000 --- a/spec/sdd/components/password-service.md +++ /dev/null @@ -1,114 +0,0 @@ -# Component: PasswordService - -## Responsabilidad -Gestionar el cambio de contraseña de usuarios autenticados. Validar contraseña actual, verificar requisitos de seguridad de la nueva contraseña, y invalidar sesiones existentes. - -## Tipo -- [x] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API REST - -``` -POST /api/v1/users/{user_id}/change-password -Authorization: Bearer -Input: { - "current_password": string, - "new_password": string, - "confirm_password": string -} -Output: { "success": true, "message": "Contraseña actualizada" } -Errors: - - 400: Validation errors (password too weak, mismatch) - - 401: Current password incorrect - - 403: Not owner - - 404: User not found -``` - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| PostgreSQL | Database | Almacenamiento de usuarios | -| Redis | Cache | Invalidation de sesiones | -| AuthService | Internal | Verificación de token | - -## Validaciones de contraseña - -| Regla | Requisito | Mensaje de error | -|-------|-----------|------------------| -| Longitud mínima | 8 caracteres | "La contraseña debe tener al menos 8 caracteres" | -| Longitud máxima | 128 caracteres | "La contraseña debe tener máximo 128 caracteres" | -| Mayúsculas | Al menos 1 | "La contraseña debe contener al menos una mayúscula" | -| Minúsculas | Al menos 1 | "La contraseña debe contener al menos una minúscula" | -| Números | Al menos 1 | "La contraseña debe contener al menos un número" | -| Caracteres especiales | Al menos 1 | "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" | -| No usar password anterior | Diferente | "La nueva contraseña no puede ser igual a la anterior" | - -## Límites - -### Alcance -- ✅ Cambio de contraseña con validación -- ✅ Requisitos de seguridad -- ✅ Invalidación de sesiones -- ❌ NO maneja recuperación de contraseña (ver ForgotPasswordService) -- ❌ NO maneja reset forzado por admin (ver AdminService) - -### Constraints -- Rate limit: 5 intentos por hora por usuario -- Timeout máximo: 1 segundo -- Máximo 3 passwords válidas en historial (evitar reutilización inmediata) - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 change_password | < 500ms | -| Rate limit | blocked attempts | 100% | -| Sesiones invalidées | después de cambio | 100% | - -## Diagrama - -```mermaid -graph LR - A[Client] -->|POST /change-password| B[PasswordService] - B -->|validate current| C[(PostgreSQL)] - B -->|validate new| D[PasswordValidator] - D -->|strong enough?| E{Valid} - E -->|yes| F[Hash + Save] - E -->|no| G[Return error] - F -->|invalidate| H[(Redis)] - H -->|remove sessions| I[All user tokens] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | started | Connect to DB | -| Ready | db_connected | Accept requests | -| RateLimited | >5 attempts/hour | Return 429 | -| Error | db_failure | Return 503 | - -## Seguridad - -- **Password hashing**: bcrypt, cost 12 (nuevo), verificar contra hash existente -- **Timing attack prevention**: usar constant-time comparison -- **Rate limiting**: 5 req/hour por user_id -- **Sesiones**: invalidar TODAS las sesiones del usuario tras cambio -- **Logs**: NO registrar passwords, solo intentos fallidos (user_id anonymized) - -## Observabilidad - -- Metrics: `password_change_total`, `password_change_failed`, `password_change_latency` -- Logs: structured JSON con request_id -- Traces: OpenTelemetry span por request - -## Tests BDD - -- Ver `spec/bdd/features/password/change-password.feature` \ No newline at end of file diff --git a/spec/sdd/components/session-store.md b/spec/sdd/components/session-store.md deleted file mode 100644 index a6f387f..0000000 --- a/spec/sdd/components/session-store.md +++ /dev/null @@ -1,75 +0,0 @@ -# SessionStore Component - -## Purpose -Manage active user sessions in Redis for fast authentication and revocation. - -## Public API - -### Methods - -#### create_session(user_id: str, token_id: str, metadata: dict) -> bool -Store a new active session. - -**Parameters:** -- `user_id`: User identifier -- `token_id`: JWT jti (unique token ID) -- `metadata`: Optional data (IP, user agent, device) - -**Returns:** True if created - -#### get_session(token_id: str) -> Session | None -Retrieve active session info. - -**Parameters:** -- `token_id`: JWT jti - -**Returns:** Session object or None if expired/revoked - -#### revoke_session(token_id: str) -> bool -Invalidate a specific session. - -**Parameters:** -- `token_id`: JWT jti - -**Returns:** True if revoked - -#### revoke_all_user_sessions(user_id: str) -> int -Invalidate all sessions for a user. - -**Parameters:** -- `user_id`: User identifier - -**Returns:** Count of sessions revoked - -#### get_user_session_count(user_id: str) -> int -Count active sessions for a user. - -**Parameters:** -- `user_id`: User identifier - -**Returns:** Number of active sessions - ---- - -## Redis Keys Structure - -``` -session:{user_id}:{token_id} -> JSON session metadata -user_sessions:{user_id} -> SET of active token_ids -rate_limit:login:{ip} -> COUNT with TTL -``` - -## TTL -- Session tokens: 15 minutes (synced with access token) -- Rate limit counters: 15 minutes - -## Dependencies -- Redis connection (via aioredis) -- TokenService (for token ID generation) - -## Configuration -```python -SESSION_TTL = 900 # 15 minutes -MAX_SESSIONS_PER_USER = 10 -RATE_LIMIT_WINDOW = 900 # 15 minutes -``` \ No newline at end of file diff --git a/spec/sdd/components/token-service.md b/spec/sdd/components/token-service.md deleted file mode 100644 index 84993b7..0000000 --- a/spec/sdd/components/token-service.md +++ /dev/null @@ -1,69 +0,0 @@ -# TokenService Component - -## Purpose -Generate, validate, and manage JWT tokens. - -## Public API - -### Methods - -#### create_access_token(user: User) -> str -Generate a new JWT access token. - -**Parameters:** -- `user`: User object with id, email, role - -**Returns:** JWT token string - -**Token claims:** -```json -{ - "sub": user.id, - "email": user.email, - "role": user.role, - "iat": current_timestamp, - "exp": current_timestamp + 900, # 15 min - "jti": uuid4() -} -``` - -#### create_refresh_token(user: User) -> str -Generate a new refresh token. - -**Returns:** JWT refresh token (7 day expiration) - -#### verify_token(token: str) -> TokenPayload -Validate and decode a JWT token. - -**Parameters:** -- `token`: JWT token string - -**Returns:** TokenPayload with claims - -**Raises:** -- `ExpiredSignatureError`: Token expired -- `InvalidTokenError`: Token invalid/malformed - -#### revoke_token(token_id: str, user_id: str) -> bool -Mark a token as revoked in session store. - -**Parameters:** -- `token_id`: JWT jti claim -- `user_id`: User ID - -**Returns:** True if revoked - ---- - -## Configuration -```python -ACCESS_TOKEN_EXPIRE = 900 # 15 minutes -REFRESH_TOKEN_EXPIRE = 604800 # 7 days -ALGORITHM = "HS256" # or RS256 with key pair -SECRET_KEY = os.getenv("JWT_SECRET") -``` - -## Security -- Tokens include unique `jti` claim for revocation tracking -- Short access token duration minimizes theft window -- Refresh tokens stored in Redis for fast revocation \ No newline at end of file diff --git a/spec/sdd/components/user-profile-service.md b/spec/sdd/components/user-profile-service.md deleted file mode 100644 index fb72c66..0000000 --- a/spec/sdd/components/user-profile-service.md +++ /dev/null @@ -1,111 +0,0 @@ -# Component: UserProfileService - -## Responsabilidad -Gestionar el perfil de usuario: consulta, actualización de datos básicos (nombre, avatar) y preferencias (idioma). - -## Tipo -- [x] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API REST - -``` -GET /api/v1/users/{user_id}/profile -Authorization: Bearer -Output: { - "id": string, - "name": string, - "avatar_url": string, - "language": "en" | "es" | "fr" | "de", - "created_at": ISO8601, - "updated_at": ISO8601 -} -Errors: 401 (unauthorized), 404 (user not found) - -PUT /api/v1/users/{user_id}/profile -Authorization: Bearer -Input: { - "name": string (optional), - "avatar_url": string (optional), - "language": string (optional) -} -Output: { perfil actualizado } -Errors: 400 (validation), 401, 403 (not owner), 404 -``` - -### Eventos (si aplica) -- `profile.updated.v1` — publicado cuando perfil se actualiza - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| PostgreSQL | Database | Datos de usuarios y perfiles | -| Redis | Cache | Cache de perfil (TTL 5min) | -| Storage Service | External | Almacenamiento de avatares | - -## Límites - -### Alcance -- ✅ CRUD de perfil de usuario -- ✅ Cambio de idioma -- ❌ NO maneja autenticación (AuthService) -- ❌ NO maneja permisos de otros usuarios - -### Constraints -- Timeout máximo: 300ms -- Rate limit: 50 req/min por usuario -- name: 2-50 caracteres, solo letras y espacios -- avatar_url: max 500 caracteres, URL válida (http/https) -- language: uno de ['en', 'es', 'fr', 'de'] - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 get_profile | < 100ms | -| Latencia | p99 update_profile | < 200ms | -| Cache hit rate | | > 80% | - -## Diagrama - -```mermaid -graph LR - A[Client] -->|GET /profile| B[UserProfileService] - B -->|cache| C[(Redis)] - B -->|fetch| D[(PostgreSQL)] - - E[Client] -->|PUT /profile| B - B -->|validate| F[Storage] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | started | Connect to DB, Redis | -| Ready | all_connected | Accept requests | -| Degraded | redis_down | Fallback to DB-only | -| Error | db_failure | Return 503 + alert | - -## Seguridad - -- Authentication: JWT Bearer token required -- Authorization: Solo el dueño puede modificar su perfil -- Input validation: Pydantic, sanitización XSS -- Rate limiting: 50 req/min por user_id - -## Observabilidad - -- Metrics: `profile_get_total`, `profile_update_total`, `profile_latency_ms` -- Logs: structured JSON con user_id (masked) -- Traces: OpenTelemetry span por request - -## Tests BDD - -- Ver `spec/bdd/features/profile/user-profile.feature` \ No newline at end of file diff --git a/spec/sdd/decisions/.gitkeep b/spec/sdd/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/sdd/decisions/.template.md b/spec/sdd/decisions/.template.md deleted file mode 100644 index 07df70b..0000000 --- a/spec/sdd/decisions/.template.md +++ /dev/null @@ -1,48 +0,0 @@ -# ADR-XXX: Título de la Decisión - -## Estado -Aceptado | Propuesto | Deprecado - -## Fecha -YYYY-MM-DD - -## Contexto -_Descripción del problema o situación que motiva esta decisión._ - -## Decisión -_Qué se decidió y por qué._ - -## Justificación -_Razones que fundamentan la decisión._ - -## Consecuencias - -### ✅ Positivas -- ... - -### ❌ Negativas -- ... - -### 🔄 Neutrales -- ... - -## Alternativas Consideradas - -### Opción A -- **Descripción**: ... -- **Pros**: ... -- **Contras**: ... -- **Razón de descarte**: ... - -### Opción B -- **Descripción**: ... -- **Pros**: ... -- **Contras**: ... -- **Razón de descarte**: ... - -## Notas -_Información adicional o follow-ups._ - -## Relacionado con -- ADR-YYY -- Feature F-XXX \ No newline at end of file diff --git a/spec/sdd/decisions/001-stack-tecnologico.md b/spec/sdd/decisions/001-stack-tecnologico.md deleted file mode 100644 index c30dcec..0000000 --- a/spec/sdd/decisions/001-stack-tecnologico.md +++ /dev/null @@ -1,63 +0,0 @@ -# ADR-001: Selección de Stack Técnico - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Necesitamos seleccionar el stack tecnológico inicial para el proyecto. El equipo tiene experiencia en Python y JavaScript/TypeScript, y requiere: -- Rápido bootstrap -- Testing BDD nativo -- Compatibilidad con el framework ARNES - -## Decisión -Usar **Python + Behave** para BDD y **FastAPI** para el backend. - -## Justificación -1. **Behave** tiene sintaxis Gherkin nativa y integración simple con Python -2. **FastAPI** ofrece validación automática con Pydantic y tests con pytest -3. Ambos tienen ecosistema maduro y documentación extensa -4. Comunidad activa y soporte a largo plazo - -## Consecuencias - -### ✅ Positivas -- Curva de aprendizaje baja (Python) -- BDD nativo con Behave (Gherkin) -- Type hints en todo el stack -- FastAPI: auto-generated docs (Swagger/ReDoc) -- Testing integrado con pytest - -### ❌ Negativas -- GIL限制了多线程性能 (puede mitigated with async) -- Menos opciones de hosting que Node.js - -### 🔄 Neutrales -- Requiere Python 3.10+ mínimo - -## Alternativas Consideradas - -### Opción A: Node.js + Cucumber -- **Pros**: Más opciones de hosting, JSON nativo, ecosistema npm enorme -- **Contras**: TypeScript requiere más setup, testing E2E más complejo -- **Razón de descarte**: Mayor complejidad inicial, menor familiaridad del equipo con TS - -### Opción B: Java + Cucumber-JVM -- **Pros**: Tipo estático, robusto, enterprise-grade -- **Contras**: Verbose, setup pesado, curva de aprendizaje alta -- **Razón de descarte**: Over-engineering para MVP - -### Opción C: Go + Godog -- **Pros**: Binarios estáticos, excelente performance -- **Contras**: BDD tooling inmaduro, less ecosystem para testing -- **Razón de descarte**: BDD ecosystem no maduro - -## Notas -- Re-evaluar si el proyecto escala a más de 50 servicios -- Considerar microservices framework si es necesario - -## Relacionado con -- Feature F-001 -- Stack: Python 3.11+, FastAPI, Behave, PostgreSQL \ No newline at end of file diff --git a/spec/sdd/decisions/002-almacenamiento-avatar.md b/spec/sdd/decisions/002-almacenamiento-avatar.md deleted file mode 100644 index 2c899ae..0000000 --- a/spec/sdd/decisions/002-almacenamiento-avatar.md +++ /dev/null @@ -1,69 +0,0 @@ -# ADR-002: Almacenamiento de Avatares - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Los usuarios pueden subir avatares personalizados. Necesitamos decidir dónde y cómo almacenar las imágenes de perfil para optimizar costo, rendimiento y mantenimiento. - -## Decisión -Usar **Storage Service externo (S3-compatible)** con URLs firmadas para avatares. - -## Justificación -1. **Simplicidad**: No requerimos procesar imágenes en nuestro servidor -2. **Costo**: S3-like storage es económico ($0.023/GB) -3. **CDN**: Los avatares se sirven desde CDN automáticamente -4. **Seguridad**: URLs firmadas con expiración evitan hotlinking -5. **Mantenimiento**: No requiere gestión de sistema de archivos - -## Consecuencias - -### ✅ Positivas -- No hay infraestructura de archivos que mantener -- Escalabilidad automática -- URLs firmadas = más seguridad -- Cache CDN = mejor performance - -### ❌ Negativas -- Dependencia de proveedor externo -- Costo de storage + egress -- Latencia extra por redirect a CDN - -### 🔄 Neutrales -- Requiere configuración de CORS - -## Alternativas Consideradas - -### Opción A: Almacenamiento local en servidor -- **Pros**: Sin dependencia externa, rápido para lecturas -- **Contras**: No escala horizontalmente, requiere backup, problemas de disco -- **Razón de descarte**: No escala bien con múltiples instancias - -### Opción B: Base de datos como BLOB -- **Pros**: Todo en un lugar, transacciones integradas -- **Contras**: PostgreSQL no optimizado para archivos grandes, backup lento -- **Razón de descarte**: degrada performance de DB, backups muy pesados - -### Opción C: Servicio dedicado de imágenes (Cloudinary/Imgix) -- **Pros**: Transformación de imágenes, CDN incluido, optimización automática -- **Contras**: Más costoso ($50+/mes), vendor lock-in -- **Razón de descarte**: Over-engineering para avatares simples - -## Implementación - -1. Cliente sube imagen a `/api/v1/profile/upload` (multipart) -2. Servicio valida tipo (jpg/png/webp) y tamaño (<5MB) -3. Servicio sube a S3 con nombre `avatars/{user_id}/{timestamp}.{ext}` -4. Servicio genera URL firmada (7 días validez) -5. URL se guarda en campo `avatar_url` del perfil - -## Notas -- Considerar WebP en el futuro para optimización -- Implementar cleanup de avatares huérfanos (job semanal) - -## Relacionado con -- Feature F-002 -- Componente: UserProfileService \ No newline at end of file diff --git a/spec/sdd/decisions/003-hashing-contrasena.md b/spec/sdd/decisions/003-hashing-contrasena.md deleted file mode 100644 index 83eb592..0000000 --- a/spec/sdd/decisions/003-hashing-contrasena.md +++ /dev/null @@ -1,83 +0,0 @@ -# ADR-003: Hashing de Contraseñas - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Necesitamos guardar contraseñas de usuarios de forma segura. La decisión debe considerar: -- Resistencia a ataques de fuerza bruta y rainbow tables -- Performance (se ejecuta en cada login y cambio de password) -- Compatibilidad con estándares de la industria - -## Decisión -Usar **bcrypt** con cost factor 12 para hashing de contraseñas. - -## Justificación -1. **bcrypt** es diseñado específicamente para password hashing lento -2. **Cost factor configurable**: permite aumentar resistencia en el futuro -3. **Resistente a GPU/rainbow attacks**: diseñado para ser lento intencionalmente -4. **Incorpora salt**: cada password tiene salt único, evitando rainbow tables -5. **Estándar de industria**: ampliamente usado (Django, Rails, bcrypt) - -## Consecuencias - -### ✅ Positivas -- Resistente a ataques de fuerza bruta -- Salt automático evitar rainbow tables -- Configurable (cost factor) -- Librerías maduras en todos los lenguajes - -### ❌ Negativas -- Más lento que MD5/SHA (es el punto, pero afecta latency) -- Enorme payload si se guarda en cookies/token - -### 🔄 Neutrales -- Requiere Python 3.11+ para bcrypt moderno - -## Implementación - -```python -import bcrypt - -def hash_password(password: str) -> str: - """Hash password with bcrypt, cost 12.""" - return bcrypt.hashpw( - password.encode('utf-8'), - bcrypt.gensalt(rounds=12) - ).decode('utf-8') - -def verify_password(password: str, hashed: str) -> bool: - """Verify password using constant-time comparison.""" - return bcrypt.checkpw( - password.encode('utf-8'), - hashed.encode('utf-8') - ) -``` - -## Alternativas Consideradas - -### Opción A: SHA-256 (con salt) -- **Pros**: Rápido, simple -- **Contras**: No es lento, vulnerable a GPU attacks, diseñado para speed no security -- **Razón de descarte**: No es resistente a hardware moderno - -### Opción B: Argon2 -- **Pros**: Ganador PHC 2015, configurable memory/CPU -- **Contras**: Más complejo de implementar, menos soporte de librerías -- **Razón de descarte**: bcrypt es más simple y suficiente para nuestro caso de uso - -### Opción C: scrypt -- **Pros**: Diseñado para ser memory-hard -- **Contras**: Más lento de configurar, configuración compleja -- **Razón de descarte**: bcrypt es más simple y ampliamente soportado - -## Notas -- Si en el futuro,我们需要 mayor seguridad, migrar a Argon2 -- No guardar passwords en logs bajo ninguna circunstancia - -## Relacionado con -- Feature F-003 -- Componente: PasswordService \ No newline at end of file diff --git a/spec/sdd/decisions/004-jwt-auth.md b/spec/sdd/decisions/004-jwt-auth.md deleted file mode 100644 index f1ec33d..0000000 --- a/spec/sdd/decisions/004-jwt-auth.md +++ /dev/null @@ -1,68 +0,0 @@ -# ADR-004: JWT Authentication Strategy - -## Status -ACCEPTED - -## Context -We need a stateless authentication mechanism for the API that: -1. Allows users to login with email/password -2. Provides secure token-based sessions -3. Supports token revocation (logout) -4. Handles token refresh without re-login - -## Decision - -We will use **JWT (JSON Web Tokens)** with the following configuration: - -### Token Structure -- **Access Token**: 15 minute expiration, contains user identity -- **Refresh Token**: 7 day expiration, used to obtain new access tokens - -### Algorithm -- **HS256** for signing (symmetric, simpler setup) -- Secret key loaded from environment variable `JWT_SECRET` - -### Claims -```json -{ - "sub": "user_uuid", - "email": "user@example.com", - "role": "user", - "iat": 1715030400, - "exp": 1715031300, - "jti": "unique-token-id" -} -``` - -### Session Management -- Active sessions tracked in **Redis** (keyed by `jti`) -- Sessions invalidated on logout -- All user sessions invalidated on password change (from F-003) - -## Consequences - -### Positive -- Stateless = horizontal scaling friendly -- Short-lived access tokens limit damage if compromised -- Refresh tokens allow long sessions without storing passwords -- Redis-based session tracking enables instant revocation - -### Negative -- Cannot revoke individual refresh tokens (need blocklist) -- Token size larger than session IDs -- Clock sync required between services - -## Alternatives Considered - -| Alternative | Why Rejected | -|-------------|--------------| -| Session cookies | Not API-friendly, CSRF issues | -| OAuth2/OIDC | Overkill for simple auth | -| PASETO | Less battle-tested | -| opaque tokens | Requires DB lookup on every request | - -## Implementation Notes -- JWT library: PyJWT -- Redis client: aioredis for async -- Both tokens stored in HttpOnly cookies for browser clients -- Access token in Authorization header for API clients \ No newline at end of file diff --git a/spec/sdd/decisions/README.md b/spec/sdd/decisions/README.md new file mode 100644 index 0000000..30913fd --- /dev/null +++ b/spec/sdd/decisions/README.md @@ -0,0 +1,7 @@ +# SDD decisions + +Put ADRs (Architecture Decision Records) here. + +Example: +- `001-use-flask.md` +- `002-use-mariadb.md` diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 8ce43e9..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package init.""" \ No newline at end of file diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d8bbfecce8450ddeba7d68d275b8f67c79882178..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 168 zcmey&%ge<81g)!nX9@%9#~=<2FhUuhIe?6*48aUV4C#!TOjW!AiOJcC>8T2td6^}8 zewvK8*yH0<@{{A^S2BDC>A9t?pOK%Ns$Z0uU6hiqpPpHwpIDTaTCAIvUzDw1T$HRI s9}m(SAFo$Xd5gm)H$SB`C)KWq4X77nX)(yc56p~=jJFtsi&%gh07W_~vj6}9 diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc deleted file mode 100644 index b6b6ed290fad66cae43285cfb7e006d218f2d1d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4581 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 diff --git a/src/api/__init__.py b/src/api/__init__.py deleted file mode 100644 index 69f75d6..0000000 --- a/src/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API init.""" \ No newline at end of file diff --git a/src/api/__pycache__/__init__.cpython-313.pyc b/src/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3b4a2d69edec4313bd6dc60cd7ef34e45f8487cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 168 zcmey&%ge<81g)!nXYvE-#~=<2FhUuhIe?6*48aUV4C#!TOjVqY0iFt(d6^}8ewvK8 z*yH0<@{{A^S2BDCslTPKpOK%Ns$Z0uU6hiqpPpHwpIDTaTCAIvUzDw1T$HSzSdgh7 s9}m(WAFo$Xd5gm)H$SB`C)KWq4X7VvX)(yc56p~=jJFv0i&%gh028JvLI3~& diff --git a/src/api/__pycache__/auth.cpython-313.pyc b/src/api/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index f8437c1725890ff95064b1413df2d9fb3cc1a322..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7198 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 XeZ~)i1!Hap*xq;J?G9+sjr4y5x2Z?= diff --git a/src/api/__pycache__/password.cpython-313.pyc b/src/api/__pycache__/password.cpython-313.pyc deleted file mode 100644 index c0535bbcc8e0a6a9d06acbbbfb156abed8791316..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3336 zcmbVOTW=f36`s9txx9+H5s4%_D@RpD*&>}rYGJ`L>?lrT39(hKnFO^8V!a$n8SS*a7{PuVMwgw{z{euns!e?M`G$kPP8PX9!x}b|I!m>!jWr;}3 zGLbb#lU4%D3Q>4pUI{L%L|qP%5FZCt!pjj7(a@TB2I-0(%m})AK_pQ066t$Ngh;TRpxO()%umYcfcR%)zNxnH9LC#G0!2uPJM>0MI~M zXjat+n6wXsInIX=>HikFgTAa6N)EyqdnFHmXErYsORs-LcBxi$h*mKuz2lIARyJ&79hb4~rk}Etr6ex|>{+MkV&cV?Zr)tIde_7i*K%x6 zS;9sEla%18H#o&8r9^Ldal^3J@oHOS7XPG*sp}7aWXm#fEtWAV zR%Yw9Ocf~9Q+)=Iu2cvvT6aC!HP)#YWDI$$;00V~6WiB4gJceFCNATIO9$tl~Y*Dr)Wq*7#o9 zZCrBfbqXU+WGB(4%8t32O26ePoG0%^wv3Wh$n((yloCk{b|7)6C_AF2d6~?HQ^px$ zZIXhMS-0GbL2OKCz$}{?N=&W_UjgaLJuk7v50iK9*r1#Zt3rlB5(EgMlTdA=$HD0R z4-fl>4kL-3;_iF9*Xl!)jmYFdc1qlcj5S3!@UZ?U6Uh}v5~Zxt4= zx^zLrLYKoBK)|2vmFRQJdSI??)f5F3 z<-qP)A6vQ;j2i73w!1)OmON4Bm)($MOe$T2;S#``L#I6zocn83TiS691PQc9<9or=a#ee*Rp&6!Ig@~;g z1w+GkE!Uw2149hR6Qots#1ufM3^kx+Dnyv6SzJ^~hKYF`k`N>HwW2BSN0XN@tAv1{ zE+uN?JKa&jbnK!<${h+}{+SB4@Zu%tlb2b={Edu5&CBL?KN9n(#t~7!$i0 zuOOrlW%2$LZKLNeq3Fwx;-h=g{*6Zb&AR&La|O-4A^h^4gXnl8I{xd_UiMGX^AFwa ztB)e1yKf&v&Nd=v`GWfF?MB?JtLCwSPNxq}Eqrlm;ZW^^??BZWs>VBG4Rx$TISK_% zD-C&M`|X|F^EiqQ9!C0i^p6)F4ZO0q_<5i)aH$@>bQl}lp&$SF|DrwNg8CW*OZDi| zlgRjCV(2jO{bvCwsx*-lP`;)t-alF#TRbh)#(Kbxqq5=R4%~eM#2Bz}5c?T){#OvY z)k!&7<`pLjK#Slf@+xpvW^+Hq_*$N|8mA@KBe%L7oU-4DOZG6TQS*3izCYSiQMn(N4xK2oRpsa@W#fG75JgW2`y_)pZDk0Ouqr7^Wm11TD88@%7qq?RG{U`3|zS@ zd#XkAT{)B~z3aYS|9^=hY2dgFKLr?VIdk%)Ge1dA9we`Pk-YM7>dWNH{Ttg$O$7~% z{PpzY&T!W)9-lkk9ehohxjve|$e#t!Z55V+*nh-!g(QZu03Rny*XF)$HF#L0#M;?w zwppqcdTd763yS)B^pv@?o}*%ov?86 z2I19_<|&`1eE=g2UpOXtr`R-P1NKtuGWc&d?g->HCPxC>d-83^DRE@<OgcMk>yqk4*uGlAm&gLp*zM^vsG5=>2!BE6|AuB8X!a{K`cL%ES7`q4 zp@H4Z{>Xl`5n9+*nu;u35q8ZcV(s3m`|^Y7R{!&%V@_+vk*NM+^!{i)ma5OMZjaW5 zA3jsj#Pr_8Z!SIyP7BImEYXypeH=T}3_uG`G&Hum_{l<(%{En}j2(xdcO)S(D>Ow$ v{E^U+diG1*5eN()hoHv<&NM~VJ0}XSaFzTAfTK8! diff --git a/src/api/auth.py b/src/api/auth.py deleted file mode 100644 index 0b2e103..0000000 --- a/src/api/auth.py +++ /dev/null @@ -1,220 +0,0 @@ -"""FastAPI endpoints for authentication.""" -from fastapi import APIRouter, Depends, HTTPException, status, Request, Header -from typing import Optional - -from src.models.auth import ( - LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, - AuthTokens, ErrorResponse, TokenValidationResult -) -from src.services.auth_service import ( - AuthService, auth_service, - InvalidCredentialsError, AccountLockedError, InvalidTokenError -) -from src.services.token_service import token_service -from src.services.session_store import session_store - -router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) - - -def get_client_ip(request: Request) -> str: - """Get client IP address from request.""" - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - -@router.post( - "/login", - response_model=LoginResponse, - responses={ - 401: {"model": ErrorResponse, "description": "Invalid credentials"}, - 429: {"model": ErrorResponse, "description": "Too many attempts"} - } -) -async def login( - request_body: LoginRequest, - request: Request, - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Authenticate user and return JWT tokens. - - Returns access token (15 min) and refresh token (7 days). - """ - try: - client_ip = get_client_ip(request) - result = auth_svc.login(request_body, client_ip) - - return LoginResponse( - success=True, - message="Login exitoso", - data=AuthTokens( - access_token=result.access_token, - refresh_token=result.refresh_token, - token_type="bearer", - expires_in=result.expires_in - ) - ) - - except InvalidCredentialsError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_credentials", - "message": "Credenciales inválidas" - } - ) - except AccountLockedError as e: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail={ - "error": "account_locked", - "message": str(e) - } - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "error": "internal_error", - "message": "Error interno del servidor" - } - ) - - -@router.post( - "/logout", - responses={ - 200: {"description": "Logout successful"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"} - } -) -async def logout( - request_body: LogoutRequest, - authorization: Optional[str] = Header(None), - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Invalidate current session (logout). - - Set revoke_all=true to invalidate all user sessions. - """ - # Extract token from Authorization header - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "not_authenticated", - "message": "Token requerido" - } - ) - - token = authorization.replace("Bearer ", "") - - try: - # Decode token to get token_id and user_id - payload = token_service.verify_token(token) - - if request_body.revoke_all: - count = auth_svc.logout_all(payload.sub) - return { - "success": True, - "message": f"Sesiones finalizadas: {count}" - } - else: - auth_svc.logout(payload.jti, payload.sub) - return { - "success": True, - "message": "Logout exitoso" - } - - except Exception as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_token", - "message": "Token inválido o expirado" - } - ) - - -@router.post( - "/refresh", - response_model=LoginResponse, - responses={ - 401: {"model": ErrorResponse, "description": "Invalid refresh token"} - } -) -async def refresh_token( - request_body: RefreshRequest, - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Get new access token from refresh token. - - Use this endpoint when your access token has expired. - """ - try: - result = auth_svc.refresh(request_body.refresh_token) - - return LoginResponse( - success=True, - message="Token refrescado", - data=AuthTokens( - access_token=result.access_token, - refresh_token=result.refresh_token, - token_type="bearer", - expires_in=result.expires_in - ) - ) - - except InvalidTokenError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_token", - "message": "Refresh token inválido o expirado" - } - ) - - -@router.get( - "/validate", - response_model=TokenValidationResult -) -async def validate_token( - authorization: Optional[str] = Header(None) -): - """ - Validate an access token. - - Returns token payload if valid and session is active. - """ - if not authorization or not authorization.startswith("Bearer "): - return TokenValidationResult( - valid=False, - error="Token requerido" - ) - - token = authorization.replace("Bearer ", "") - - try: - payload = token_service.verify_token(token) - - # Also check if session is still valid - if not session_store.is_session_valid(payload.jti): - return TokenValidationResult( - valid=False, - error="Sesión revocada" - ) - - return TokenValidationResult( - valid=True, - payload=payload - ) - except Exception as e: - return TokenValidationResult( - valid=False, - error=str(e) - ) \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py deleted file mode 100644 index 3b5d872..0000000 --- a/src/api/main.py +++ /dev/null @@ -1,80 +0,0 @@ -"""FastAPI application for User Profile Service.""" -from fastapi import FastAPI, HTTPException, Header -from typing import Optional - -from src.models.profile import Profile, UpdateProfileRequest, ProfileResponse -from src.services.profile_service import profile_service - -app = FastAPI(title="User Profile Service", version="1.0.0") - - -def verify_owner(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@app.get("/api/v1/users/{user_id}/profile") -async def get_profile( - user_id: str, - authorization: Optional[str] = Header(None) -) -> ProfileResponse: - """Get user profile.""" - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - profile, status, error = profile_service.get_profile(user_id) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.put("/api/v1/users/{user_id}/profile") -async def update_profile( - user_id: str, - request: UpdateProfileRequest, - authorization: Optional[str] = Header(None) -) -> ProfileResponse: - """Update user profile.""" - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - # Verify ownership - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_owner(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para editar este perfil") - - profile, status, error = profile_service.update_profile(user_id, request) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.get("/health") -async def health(): - """Health check endpoint.""" - return {"status": "healthy", "service": "user-profile-service"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/api/password.py b/src/api/password.py deleted file mode 100644 index 1368e57..0000000 --- a/src/api/password.py +++ /dev/null @@ -1,89 +0,0 @@ -"""FastAPI endpoints for password management.""" -from fastapi import APIRouter, HTTPException, Header -from typing import Optional - -from src.models.password import ChangePasswordRequest, ChangePasswordResponse -from src.services.password_service import password_service - -router = APIRouter(prefix="/api/v1/users", tags=["password"]) - - -def verify_ownership(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@router.post("/{user_id}/change-password", response_model=ChangePasswordResponse) -async def change_password( - user_id: str, - request: ChangePasswordRequest, - authorization: Optional[str] = Header(None) -) -> ChangePasswordResponse: - """ - Change user's password. - - Requires authentication and ownership of the account. - """ - # Verify authentication - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - # Verify ownership - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_ownership(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para modificar esta cuenta") - - # Change password - success, status, error = password_service.change_password( - user_id, - request.current_password, - request.new_password, - request.confirm_password - ) - - if not success: - if status == 400: - raise HTTPException(status_code=400, detail=error) - elif status == 401: - raise HTTPException(status_code=401, detail=error) - elif status == 429: - raise HTTPException(status_code=429, detail=error) - elif status == 404: - raise HTTPException(status_code=404, detail=error) - else: - raise HTTPException(status_code=500, detail="Error interno") - - return ChangePasswordResponse( - success=True, - message="Contraseña actualizada exitosamente" - ) - - -@router.post("/{user_id}/validate-password") -async def validate_password( - user_id: str, - password: str, - authorization: Optional[str] = Header(None) -) -> dict: - """ - Validate password strength (for pre-check before form submission). - - This endpoint is useful for real-time validation in the UI. - """ - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - is_valid, error = password_service.validate_password_strength(password) - - return { - "valid": is_valid, - "error": error if not is_valid else None - } - - -# Register router in main app -def include_routes(app): - """Include password routes in the FastAPI app.""" - app.include_router(router) \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 74a89f4..0000000 --- a/src/main.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Main FastAPI application.""" -import os -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse - -from src.api.auth import router as auth_router -from src.api.password import router as password_router - -# Profile routes (from the original api/main.py) -from src.models.profile import Profile, UpdateProfileRequest, ProfileResponse -from src.services.profile_service import profile_service - -app = FastAPI( - title="ARNES API", - description="User management API with authentication", - version="1.0.0" -) - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers -app.include_router(auth_router) -app.include_router(password_router) - -# Serve UI static files -ui_path = os.path.join(os.path.dirname(__file__), "ui") -if os.path.exists(ui_path): - app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") - -def verify_owner(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@app.get("/api/v1/users/{user_id}/profile") -async def get_profile( - user_id: str, - authorization=None -) -> ProfileResponse: - """Get user profile.""" - if not authorization: - from fastapi import HTTPException - raise HTTPException(status_code=401, detail="No autorizado") - - profile, status, error = profile_service.get_profile(user_id) - - if status == 404: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.put("/api/v1/users/{user_id}/profile") -async def update_profile( - user_id: str, - request: UpdateProfileRequest, - authorization=None -) -> ProfileResponse: - """Update user profile.""" - from fastapi import HTTPException - - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_owner(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para editar este perfil") - - profile, status, error = profile_service.update_profile(user_id, request) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.get("/") -async def root(): - """Redirect to UI login page.""" - return RedirectResponse(url="/ui/login.html") - - -@app.get("/health") -async def health(): - return {"status": "healthy"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index 418a2f8..0000000 --- a/src/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Models init.""" \ No newline at end of file diff --git a/src/models/__pycache__/__init__.cpython-313.pyc b/src/models/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9f9942d792b3b60ed81a34af394df88e4b95bbfd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmey&%ge<81g)!nX9@x7#~=<2FhUuhIe?6*48aUV4C#!TOjSI-`6;P6#R{2unI(FD znvA#D`)wSd^Dqteci!l&xP}l&qf% uHb6f<9%Mv(yk0@&Ee;!?U};XOT@f452$0pqAZtG`Gcq#XVh}1~0dfF?ye%RC diff --git a/src/models/__pycache__/auth.cpython-313.pyc b/src/models/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index 8aa6182a8e04b194402ffaf0f92cc148427abcfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4334 zcmcIn-A^0Y6`!%k-(P_FFpz`^$NU+LkPseqwGZ1D-YVg$QdOxv=iUKKmj+d8NAj6FzjN;O zopa9b+%w0CL{xz554Zne{5~uQ-{Hf_74Qbfe}=)=f+7$>5f$H@nD-H1-cS5_iAXYH z{<%OtNP=uE&4uzI5|V|CFeoU2K0yfC$VxOn{?yzX4$BEuXPjxm7QjgO!%wH%VueFc=+g_p!$3C z1=WeH<6C5XZgfrCP%C=1Y`t=0C7l+D!CuAl&qBnWV&K8C7ot>@dt18J6AIhlPf_@^ZM6R6)G{8675`d~}wLrMVf2uq3Z zDGuqN!~l;nJbsG%l?1bo72=R6$re1t@C3u#TJUy;C)qk3r+8ZFWb3C2ZG{lN0<6}B zT1KsIpjOX_4<6wn?MjApl+)Q>H@;-9>eZiUn-x;r zw(j~3&9XbEH`WcpGNx6fZ>-y0xS!c6Mt7orX>zK&{$E(eg5A&dFI$`Ix_n0~7IjK< zL|-8~eU)`3gwKT**@4$_2E_T$BC8cY36!O5z)iQhWq8u8t{CN`9+=|>v`WSNie>CV zHb^=FBV8!EQE(G|!y+2#M*uUMr`c5)xk0`Z`2Zf!mWDw5S~zU$`E7ukJph2s%BC+` zoZ!Ye;RbQkhoTn+cQ6Jc6wiW&}i?j(>HOaeFT{GXkQv>v~}&Q{PCXCHoi5rJ+v>4vnhMGK5|k| z_?#ym^2AAvab(Ox#+=kRN5&g{18=0g4?lDIUcx$e>W^OE-w zq^E4PL32K&|6Z#9KWtS}BvEe5w!6`%P?T8W&?9uPJ%GU3>BLDzp~&-d3~1Gcxy{p- zYsqz(=*BsuG8WqHXyhd|w{jESBe!sa%eqE@fYCEHkQ=yAl&zp55A6P@j2<4fmzL&b zqbgIqXjV(qjRMmQ*qCZmz1&V-l71tweoOlO2p=D#7)9|A#YI{62o)$$N#{XemSr26 zt2@QL+*2nru{B?ZB${aSUfZF24%XEtM!q zmc!YYoA8cy9^s=Q(Aj-Tjd^~hR*@@s@}FhEQdT54!Nv_yZe#NWWPQW1OlrCzsFGAG z>kF;~jf%!AL+X;yjNyvXvT0U$X&8rR9;zSXh?M~rhl>LA5t^XDz0)6nzyRHV<8$|h z(|LPqW_xL0y4^@;b_bmFLwHp7rH6;tZ!*q)=tQp_NH=*HaFKIiIHfAC`o1u*1CIAp z>|j+wBm(Y@xXDFc+fRBYENgZ*&-xRFcs&%bEB#ZgVwBEPhY^ghY)!-Dg>3W^XEM{X zGJBEBCSW0g0=Gl4uA|FjFN3F@-e;J>2hua{7qt8z`vtSctXU^+$-zI^og9PLXYDiZ zDWBPyML0uObs5ev8Bc8}0hB^FOudSMN9?Mug4!ztmpNz#{)~K#f}KMbIcOTSC~#w^ z4!YLpxw89W@A0D3qX2)U14-cyLd%OC^f_q$o5b;&@$YqHN(A~aU@>MS^hr_1;TY8M z2sFcqX+lhL^0jgFL$I;C76!IJZDM>;@PH?)Cas z!aRpG&nEse%RdG^Pu)cvvFgZrp)oqqfo(ATC5XTHMNw=B55E<1j*$CC=>JBz z+6?-|yjUM?3V3hEV`2s$;MTkn5a-2uZ&Sc~vrQ6j)n}Um+?q+BI3w17))esGjQYeS b5mk6^MkR5G+YL37k|^)Y{uSoPYo^ diff --git a/src/models/__pycache__/password.cpython-313.pyc b/src/models/__pycache__/password.cpython-313.pyc deleted file mode 100644 index af6128878560e929886dc4ac9e58d507f1a01392..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3206 zcmbVOO>ERg6dteb{Yf_2O-lGjbw~niX}tWmP)Z3=kqE!Sp;8-zWxSKbW!DZfULf06 zLPBa$Ig~>qj^sBp?{CK6 zy!U2mZEYbRlN98MM`b3j zCL4(+ttFbQ$99XMEsF=-IB*k9+(f`l0yovfO^%7Vmdd(QCg+!3R!}aPMY~{nw(BS* zw?K zk7zL_=`!qH)Fe&T;(J9F*AseDOKb-1NRrf2T8q}YS7NC^mTr=@Y3+MstR>)Q0)8ua zOYauKMfJ3n)e~BWp4cU6oomU5((X_|>OE`I(IT^`#Vd((f7^H=QwB zN9{DdSf<>Yi^Cd~-e?x$r;NHxu~6G~#*~Q&W>uZTjaIUQWyYxEH3Yes-%_$2qez`G zZ`^M!P;N1M0?)(GTA{L0_cE6RA9GjHQThbE{M3oaFu=f*c_s41T9J`<=;elWWyPTD zjM}WE0Qae_5UOUaP}orSg-d>%QLoG#D+bQ$O@aXiUhNzBqbV{H9RML`ggUgEu&A55h{&r`*t4rPj21Q(9Wl{Xd3WTxd&MmcLo!)^a+Sh{!nid}M* z?K_$+EAq%d{|9HwyIx|8yLfipvSTVGbMoF#+$tAMus8cF%eJ?jx_wM9ymaG>Ev|rN zi88mcX{7sLPjBD3O|Naq8C&)KxAhVI>_?w`{MJ5wv%c|lHNXGBJGy?hd}JAahLGO7 zjhHHwPf*JSw71lkQB^gUlY9}1hTj4QTc+Cr2keuxou{hl zQ;#woUrc^JIlcPU_WPN>nM_|G)Mh(}tLfqYB<#8++|O*B$!uK2ZFS9|p9k(|4$ou` z2h#D`PP>}6A7rxElQq~cu^M#@CRp);3W$Yh3hEwYm{3{sLWXgOPR|-wzlGbBtpVNO zGFdPL@w(m4Z|@@ z)G+)O!+@hL7jc|6jEiNn7)dgQ0XG8oinar(4B~`g6kN+N7?xX>2I7lw;65mtj^ld4 zOM!zti}DU6_=53Uts-1?CF+g41-1^^-N^1TAY8!4GEC27_W80$(HOTFZWe!^;maW) z2q^yw$Yoj zS$RvKACo(#`8UkPIH>m0-c-gWj=YFCD)EuKxETmWu_+Y(A z`jDVYhKu{sdDktn9M199WLuFucRc;bLxN^J0|bw!|8ZN#wc`)kvY#Fc;XsZ54TszW zM(Ao zVlfbQq7Njvd;E^%!`5`JS5$}lRYzJ&u=8P5Z@qlOdnYw&@CJkB$&LvK%iS_$Fcnv Ij)U|02LjF)9RL6T diff --git a/src/models/__pycache__/profile.cpython-313.pyc b/src/models/__pycache__/profile.cpython-313.pyc deleted file mode 100644 index be23d708b5291f01effb52010bce27bfec2492db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4548 zcmbtXO-vlw5q|wM(=)>iJ^b4kHVruT&SpL14cJ~F>)i$aV#6l1AyzU{r#;g^?+(-W zb;B;?V#!KkpB5ih#;1i-AW|fp9Af1ZX>-US2S#%MT3IEloVY|-uvsa&r0R9gFkoy} zN=s9(Ue&AD{pwYHRkIU|MF>2<_|1RK5ODv*N$Cmr%+3rjpAnT%qH-#KgB#!|rywuf z;0FXMFkZYN4g_dmAV`A)5|tEkjIsrd0lak6bPTHJ5`r7< z(`|$8;RddlMlS2hV>oCtdd>tBi)OrU^JvhY?40HB&}l+BI5@9zRQN7Yc`Bv@>7Xi{ zhGl694k)StHKwR-W)Ddv9dmW;#-{umo5Y(;y1bc9Ztl&w5p zDPCUph1n`Xep-SFqJ~THV{jwfNs@@nogVd)%uw(udlW05n^kg#1IcMCdOoYzQ+md* zlP;&wTG(_Ko%h9(H>`NWZ@uC7-tb3nc=|Pd!;m$3hZ>5RpZQ`5lG*A}Y%6Es3#e`@ zvxK5)4<+gP>QIHeMCs+BR+_^6I7UbS%m;TgVlrOK#g9Ul;FWI$Qqec10 z>#|mmwY8sR*TyD_vbiqbEy#D*=*-$Db4B^*-w+bN#6M?rn~=-=bsmNr{2;#rvLPPz zqf9@__}h%X&Ht^oWv%sUvG&?p^xB3Te-MJ@gzQTK3<;J5qriMdMz~TS=xqdMhlV^? z3X0NN7BuDHf`IcA$qyP~?tA5+@lsqF8NS)0OitU5a>r069A`>_T7aXST;b;=uIcgVN2fRHk3GKr==w(UYoDi{ zq&AMVd>(re^JnYLorUJkV)MC!^XD-s9@!>RIKu96g$yN{+>pkyU(?)(rU6Dw=M3PZ zns#?u&mk_rOxCn9liE(s%o}+N1d^s@tqd@N?NC(f`Ml-m=qX#%C|=+}H{n%Nj1*VM zTOaRvjba43RhgV_+mnVfVP)xCxU?I^B07X#M7BZGApRXQ0|n5b=ACKBz*oJ8YcVRB z6KNPZB)F^nS0KM4n_}xfLRI&#ZpIttGmH9aUm0LRmfbhnA-;g%cNHNvJ4ZJVf+WpniU|7--oD#J71d zP_vNPCNL~!c5v7cNRW}%Z4su=qa@fcpDqU4OUoDg+4AN7{mVPp^6h9i5Lu}IhQQz* z6pxpIU-`ka;qytPJQp2qO+tWr+s|u_O ztir-swqyNaItq4D2@umUHCEbLtuArU!^wwsWY8&sb>v9v_@UOaT0`UGLZa5KA7Raq zpESe9-D$&iwh$oaI{cZ!Y9DLKS?6Tt$CW9s$x9AVgb5l$QjLVAcO7sEj&*KcsyCsE zM|LR(VlE?f4Bo5Jbcqi*eXac!6kQ^)yEUVo1 z!<@Ch;CF$wjTyz@CK`MA&SK54k1rix?u0tu*16u+TWITDZ+oZE_Rb3>*sBkfSdTto zei;IW`32zYWqv80?Aw;!A&O{E&m*~j) zcBRmEYKqyf|-^WkrL(-4r5|YbE_VDluPOl=_DaL>LyAQ$ygs{a+Pd?lxz%Hw+T`ND?LADagVER0=56VEY*uQ*oX@Hrn zc0Q|r+OyxRV@HHV-f{2{8TgeS3=@2Iyl(B4e`WEntLQ-&aDg%xEOS1vftLNnRl`&o zk0h$f)pZz}ZKzhp7T(p|snWCpDtv@cEa=L-(w2N_foD~n-PKSc^zuO1yZ*!O_7;2z zMS`DXcL2e=Jkc`A+CPj{F=bPzV&~XImN{Q diff --git a/src/models/auth.py b/src/models/auth.py deleted file mode 100644 index 18dcb22..0000000 --- a/src/models/auth.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Request/Response models for authentication.""" -from pydantic import BaseModel, EmailStr, Field -from typing import Optional -from datetime import datetime - - -class LoginRequest(BaseModel): - """Login request body.""" - email: EmailStr = Field(..., max_length=255, description="User email") - password: str = Field(..., min_length=1, description="User password") - - -class TokenPayload(BaseModel): - """JWT token payload.""" - sub: str = Field(..., description="User ID") - email: str = Field(..., description="User email") - role: str = Field(default="user", description="User role") - iat: int = Field(..., description="Issued at timestamp") - exp: int = Field(..., description="Expiration timestamp") - jti: str = Field(..., description="JWT ID for revocation") - type: Optional[str] = Field(default=None, description="Token type (access/refresh)") - - class Config: - extra = "allow" # Allow extra fields like 'type' from JWT - - -class AuthTokens(BaseModel): - """Authentication tokens response.""" - access_token: str = Field(..., description="JWT access token") - refresh_token: str = Field(..., description="JWT refresh token") - token_type: str = Field(default="bearer", description="Token type") - expires_in: int = Field(..., description="Access token TTL in seconds") - - -class LoginResponse(BaseModel): - """Successful login response.""" - success: bool = Field(default=True) - message: str = Field(default="Login exitoso") - data: Optional[AuthTokens] = None - - -class RefreshRequest(BaseModel): - """Token refresh request.""" - refresh_token: str = Field(..., description="Valid refresh token") - - -class LogoutRequest(BaseModel): - """Logout request body.""" - revoke_all: bool = Field(default=False, description="Revoke all user sessions") - - -class ErrorResponse(BaseModel): - """Error response model.""" - error: str = Field(..., description="Error code") - message: str = Field(..., description="Human-readable message") - details: Optional[dict] = None - - -class TokenValidationResult(BaseModel): - """Token validation result.""" - valid: bool - payload: Optional[TokenPayload] = None - error: Optional[str] = None \ No newline at end of file diff --git a/src/models/password.py b/src/models/password.py deleted file mode 100644 index 608a6bb..0000000 --- a/src/models/password.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Password validation models.""" -from pydantic import BaseModel, Field, field_validator -import re - - -class ChangePasswordRequest(BaseModel): - """Request model for changing password.""" - - current_password: str = Field(..., min_length=1, description="Current password") - new_password: str = Field(..., min_length=8, max_length=128, description="New password") - confirm_password: str = Field(..., description="Confirm new password") - - @field_validator("new_password") - @classmethod - def validate_password_strength(cls, v: str) -> str: - """Validate password meets security requirements.""" - if len(v) < 8: - raise ValueError("La contraseña debe tener al menos 8 caracteres") - if len(v) > 128: - raise ValueError("La contraseña debe tener máximo 128 caracteres") - if not re.search(r'[A-Z]', v): - raise ValueError("La contraseña debe contener al menos una mayúscula") - if not re.search(r'[a-z]', v): - raise ValueError("La contraseña debe contener al menos una minúscula") - if not re.search(r'\d', v): - raise ValueError("La contraseña debe contener al menos un número") - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', v): - raise ValueError("La contraseña debe contener al menos un carácter especial (!@#$%^&*...)") - return v - - @field_validator("confirm_password") - @classmethod - def validate_match(cls, v: str, info) -> str: - """Validate passwords match.""" - return v - - -class ChangePasswordResponse(BaseModel): - """Response model for password change.""" - - success: bool - message: str - - -class PasswordValidationError(BaseModel): - """Error model for validation failures.""" - - field: str - message: str \ No newline at end of file diff --git a/src/models/profile.py b/src/models/profile.py deleted file mode 100644 index 15a59d9..0000000 --- a/src/models/profile.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Models for User Profile service.""" -from datetime import datetime -from typing import Literal -from pydantic import BaseModel, Field, field_validator - - -class Profile(BaseModel): - """User profile model.""" - - id: str - name: str = Field(..., min_length=2, max_length=50) - avatar_url: str = Field(default="", max_length=500) - language: Literal["en", "es", "fr", "de"] = "en" - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - """Validate name: only letters and spaces.""" - if not v.replace(" ", "").replace("á", "").replace("é", "").replace("í", "").replace("ó", "").replace("ú", "").replace("ñ", "").isalpha(): - raise ValueError("Nombre inválido: solo letras y espacios") - return v - - @field_validator("avatar_url") - @classmethod - def validate_avatar_url(cls, v: str) -> str: - """Validate avatar URL: must be http or https.""" - if v and not v.startswith(("http://", "https://")): - raise ValueError("Solo se permiten URLs http o https") - return v - - def to_dict(self) -> dict: - """Convert to dictionary.""" - return { - "id": self.id, - "name": self.name, - "avatar_url": self.avatar_url, - "language": self.language, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat() - } - - -class UpdateProfileRequest(BaseModel): - """Request model for updating profile.""" - - name: str | None = Field(None, min_length=2, max_length=50) - avatar_url: str | None = Field(None, max_length=500) - language: Literal["en", "es", "fr", "de"] | None = None - - @field_validator("name") - @classmethod - def validate_name(cls, v: str | None) -> str | None: - if v is not None and not v.replace(" ", "").isalpha(): - raise ValueError("Nombre inválido: solo letras y espacios") - return v - - @field_validator("avatar_url") - @classmethod - def validate_avatar_url(cls, v: str | None) -> str | None: - if v is not None and not v.startswith(("http://", "https://")): - raise ValueError("Solo se permiten URLs http o https") - return v - - -class ProfileResponse(BaseModel): - """Response model for profile operations.""" - - id: str - name: str - avatar_url: str - language: str - created_at: str - updated_at: str \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py deleted file mode 100644 index 79fd546..0000000 --- a/src/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Services init.""" \ No newline at end of file diff --git a/src/services/__pycache__/__init__.cpython-313.pyc b/src/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index cedef80e813d1bd28371382dcab41b5c9549a8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 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 N-gVRUC23)+{2%YTXjlLM diff --git a/src/services/__pycache__/password_service.cpython-313.pyc b/src/services/__pycache__/password_service.cpython-313.pyc deleted file mode 100644 index 00552c6c33852c15c54d57e71741d9ad9015ee46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7404 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 diff --git a/src/services/__pycache__/profile_service.cpython-313.pyc b/src/services/__pycache__/profile_service.cpython-313.pyc deleted file mode 100644 index ac5049225754c2c249f6f40f5562cd528a58bce3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3056 zcmbtWU2GFa5Z?Q<&u7Pmgd`*+Qx1UXz$VfA#J4|Cw0!`>N+ z9}s<@3Q{XtsZoJSg}#wWmFQa^sd(Wr7F3v%kdTU0(WgL=klL5d+?|6R(e{CrJv+Oz zGrRlE?945;v`7Tn#UKB+{)`dwI}YlN51GYDU}lL*C{Y=e-Or3Ol#!9=_Os(0<$Rvs z&yNdK7?041OjeTtq6*zajf}E^6&lS&2gqn>l8S|BTC8l&IwqA5($aCOXv(Td&sc^j z56M%yWy{CPj%AyUBNs~r%TS)8YMSxHyzZK=HDv-HJz%*e)r)D?i-uObRoQ7=5e_HK z(`D0fjc{MTi-&{7?}3>mIYJq@8>=#uQ(4NZTsNVD$^(t4LN2OCa$+u~Mu(z=*HW80sj_%m)MA;CQ*Kpp-DAGQ-IM* zFB#q>MCYjp+g1kaSRF4>K+Dz(mR*p`h~{*FrlS~4cu(xm3mp6xw*r|ZZ<8!30vEI_ znMB#LR`-=m3V{0rU>wux#=Vqg*_NwKm5fswz^%_Q9`BgN;|^{rd%H3xN>gTrTBm5f zlqpzlMyKG*Avo@-j6;nKoD_ZSWI~HtIH63R^F%E$u6do!cUS5H^q|uY--7x5xZw@1wU+rJOYEIGP_492s{sk|!e;2UNmYE%`~7|yDa!U9%H z8ijZ|QR&|Ym$&qyRWW6RKt>2;^o0w-2Ch*4644n$!&|qN@v!4%-IfpDnWJWYk1V@+p{_gE|M@wPr?dQu?Q%CzW-Mcpox z^@17hGcvlpoP>Qo)ajj{ba}5%4e0-OD^Lr!vePVG1qSFEAQN*e#ptGZblL(Wa4SvW zI03|4yWBI);eZRBcYs_VFCwH%uC?{n+6QWDcGtRkpT&f(D$La7dFc9R+g)q)Ccu@?3 z5pEqx^OeKWUej%QN%FCC^1eNaBu)BY0o-komwT?F3F~Clx&yk`O?$7aE6IF#>X2Rfu5v&KpecU zEc>{7@q+2rAE4L04F3k1jEWn9Tp$Zcl4!fGTvhJHH`UrkYpJ%&=P#YVQmCdj+~jT^ zo=*+Wi^Cz-G|ZHZnE-ho;;nG_H-p&bvg~A|`@l4|V=KW4ua#uESFa9M)T>dT)~Nd$ z3Ft2E1-g+2rH~ExeT@WkmoPYrnZs|Bex0n3g8o%K1Dz#HSq?G^8-~*%DJVxx5%or~ zL-fB#CxmwOV|20J?P(7?cX-D_kLonFIiKK!*64)Z^MJWOtmkAlAb5M>#ygo z<-Yr5CVF4&ew^yK!d-z-?w=R?{ZOXOa2WW&>kUeH5YRh;X)FX;Cd+2IEUz*gNG@O1>Sj=z6W!VkcqPi$O6YO%tJE#i1a=p-G7j=myvOX V>3l|fnqx^@|Gn0M&r-gne*opB&r1LR diff --git a/src/services/__pycache__/session_store.cpython-313.pyc b/src/services/__pycache__/session_store.cpython-313.pyc deleted file mode 100644 index 37710faab649a9d89ec0f937203a3710ea96b043..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5603 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 T#2v!qEKfT7UXOgcoo(*FvA(2j diff --git a/src/services/auth_service.py b/src/services/auth_service.py deleted file mode 100644 index 5482da6..0000000 --- a/src/services/auth_service.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Authentication service for login/logout operations.""" -from typing import Optional -from dataclasses import dataclass - -from src.models.auth import LoginRequest, AuthTokens, TokenPayload -from src.services.token_service import TokenService -from src.services.session_store import session_store as _session_store -from src.services.session_store import SessionStore as SessionStoreClass - - -class AuthError(Exception): - """Base authentication error.""" - pass - - -class InvalidCredentialsError(AuthError): - """Invalid email or password.""" - pass - - -class AccountLockedError(AuthError): - """Account temporarily locked due to rate limiting.""" - pass - - -class InvalidTokenError(AuthError): - """Token is invalid or expired.""" - pass - - -@dataclass -class AuthResult: - """Result of authentication operation.""" - success: bool - access_token: Optional[str] = None - refresh_token: Optional[str] = None - token_type: str = "bearer" - expires_in: int = 900 - error: Optional[str] = None - - -class AuthService: - """ - Service for user authentication. - - Handles login, logout, token refresh, and session management. - """ - - def __init__( - self, - token_svc: Optional[TokenService] = None, - session_store: Optional[SessionStoreClass] = None - ): - self._token_service = token_svc if token_svc else TokenService() - # Use the singleton session store to ensure consistency - self._session_store = session_store if session_store is not None else _session_store - self._rate_limit_store = {} # ip -> list of attempt timestamps - self._locked_accounts = {} # email -> lock_until timestamp - - def _check_rate_limit(self, ip_address: str) -> None: - """ - Check if IP is rate limited. - - Raises: - AccountLockedError: If rate limit exceeded - """ - import time - now = time.time() - window = 900 # 15 minutes - - if ip_address not in self._rate_limit_store: - self._rate_limit_store[ip_address] = [] - - # Clean old attempts - self._rate_limit_store[ip_address] = [ - ts for ts in self._rate_limit_store[ip_address] - if now - ts < window - ] - - max_attempts = 10 - if len(self._rate_limit_store[ip_address]) >= max_attempts: - raise AccountLockedError("Demasiados intentos. Intenta de nuevo en 15 minutos.") - - def _record_attempt(self, ip_address: str) -> None: - """Record a login attempt for rate limiting.""" - import time - now = time.time() - - if ip_address not in self._rate_limit_store: - self._rate_limit_store[ip_address] = [] - - self._rate_limit_store[ip_address].append(now) - - def _get_user_by_email(self, email: str) -> Optional[dict]: - """ - Get user from database by email. - - In production, this queries the users table. - For testing, returns mock users. - """ - # Mock user database - mock_users = { - "alice@example.com": { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "$2b$12$F6csT2WTMzNZgF1JevQN1uH.GcfuJId4J4e7CWTuUjeM4MQ6z2mUW", # SecurePass123! - "role": "user", - "active": True - }, - "bob@test.com": { - "id": "user-002", - "email": "bob@test.com", - "password_hash": "$2b$12$F6csT2WTMzNZgF1JevQN1uH.GcfuJId4J4e7CWTuUjeM4MQ6z2mUW", # Same password for testing - "role": "user", - "active": True - } - } - - return mock_users.get(email.lower()) - - def _verify_password(self, password: str, password_hash: str) -> bool: - """ - Verify password against hash. - Uses bcrypt for secure comparison. - """ - import bcrypt - try: - return bcrypt.checkpw( - password.encode('utf-8'), - password_hash.encode('utf-8') - ) - except Exception: - return False - - def login(self, request: LoginRequest, ip_address: Optional[str] = None) -> AuthResult: - """ - Authenticate user with email and password. - - Args: - request: LoginRequest with email and password - ip_address: Client IP for rate limiting - - Returns: - AuthResult with tokens if successful - - Raises: - InvalidCredentialsError: If credentials are wrong - AccountLockedError: If rate limit exceeded - """ - try: - # Check rate limit - self._check_rate_limit(ip_address or "unknown") - - # Get user - user = self._get_user_by_email(request.email) - - if not user: - self._record_attempt(ip_address or "unknown") - raise InvalidCredentialsError("Credenciales inválidas") - - # Check if account is active - if not user.get("active", True): - raise InvalidCredentialsError("Cuenta desactivada") - - # Verify password - if not self._verify_password(request.password, user["password_hash"]): - self._record_attempt(ip_address or "unknown") - raise InvalidCredentialsError("Credenciales inválidas") - - # Generate tokens - access_token, token_id = self._token_service.create_access_token( - user["id"], - user["email"], - user["role"] - ) - refresh_token = self._token_service.create_refresh_token( - user["id"], - user["email"], - user["role"] - ) - - # Store session - self._session_store.create_session( - user["id"], - token_id, - ip_address - ) - - return AuthResult( - success=True, - access_token=access_token, - refresh_token=refresh_token, - expires_in=900 - ) - - except (InvalidCredentialsError, AccountLockedError): - raise - except Exception as e: - raise AuthError(f"Login failed: {str(e)}") - - def logout(self, token_id: str, user_id: Optional[str] = None) -> bool: - """ - Invalidate a specific session/token. - - Args: - token_id: JWT jti (token identifier) - user_id: User ID (optional, for validation) - - Returns: - True if successful - """ - return self._session_store.revoke_session(token_id) - - def logout_all(self, user_id: str) -> int: - """ - Invalidate all sessions for a user. - - Args: - user_id: User ID - - Returns: - Number of sessions invalidated - """ - return self._session_store.revoke_all_user_sessions(user_id) - - def refresh(self, refresh_token: str) -> AuthResult: - """ - Get new access token from refresh token. - - Args: - refresh_token: Valid refresh token - - Returns: - AuthResult with new access token - - Raises: - InvalidTokenError: If refresh token is invalid - """ - try: - # Verify refresh token - payload = self._token_service.verify_token(refresh_token) - - # Check token type - if getattr(payload, 'type', 'access') != 'refresh': - raise InvalidTokenError("Invalid token type") - - # Check if session is still valid - if not self._session_store.is_session_valid(payload.jti): - raise InvalidTokenError("Session revoked") - - # Generate new access token - access_token, token_id = self._token_service.create_access_token( - payload.sub, - payload.email, - payload.role - ) - - # Create new session for the new token - self._session_store.create_session(payload.sub, token_id) - - return AuthResult( - success=True, - access_token=access_token, - refresh_token=refresh_token, # Return same refresh token - expires_in=900 - ) - - except Exception as e: - if isinstance(e, (InvalidTokenError, Exception)): - raise InvalidTokenError(f"Refresh failed: {str(e)}") - raise - - def validate_token(self, token: str) -> tuple[bool, Optional[TokenPayload], Optional[str]]: - """ - Validate an access token. - - Returns: - Tuple of (is_valid, payload, error_message) - """ - try: - payload = self._token_service.verify_token(token) - - # Check if session is still valid - if not self._session_store.is_session_valid(payload.jti): - return False, None, "Session revoked" - - return True, payload, None - - except Exception as e: - error_msg = str(e) - if "expired" in error_msg.lower(): - return False, None, "Token expired" - return False, None, "Invalid token" - - -# Singleton instance - use the shared session_store singleton -auth_service = AuthService() -auth_service._session_store = _session_store \ No newline at end of file diff --git a/src/services/password_service.py b/src/services/password_service.py deleted file mode 100644 index b34ff8c..0000000 --- a/src/services/password_service.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Password Service - business logic for password management.""" -import re -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Literal - - -@dataclass -class PasswordHistory: - """Tracks password history to prevent reuse.""" - user_id: str - hashed_passwords: list[str] = field(default_factory=list) - max_history: int = 3 - - def add(self, hashed_password: str): - """Add password to history, maintaining max size.""" - self.hashed_passwords.append(hashed_password) - if len(self.hashed_passwords) > self.max_history: - self.hashed_passwords.pop(0) - - def is_reused(self, password: str) -> bool: - """Check if password was used recently.""" - return password in self.hashed_passwords - - -class PasswordService: - """Service for password management operations.""" - - MAX_HISTORY = 3 - RATE_LIMIT_ATTEMPTS = 5 - RATE_LIMIT_WINDOW_HOURS = 1 - - def __init__(self): - self._users: dict[str, dict] = {} # Mock user storage - self._password_history: dict[str, PasswordHistory] = {} - self._rate_limits: dict[str, list[datetime]] = {} - self._sessions: dict[str, list[str]] = {} # user_id -> list of session tokens - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock user data.""" - self._users = { - "user-123": { - "id": "user-123", - "email": "user@example.com", - "password_hash": "OldPass123!" - }, - "user-456": { - "id": "user-456", - "email": "other@example.com", - "password_hash": "OtherPass456!" - } - } - - def _hash_password(self, password: str) -> str: - """Hash password (mock implementation).""" - # In production: bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) - return password - - def _verify_password(self, password: str, hashed: str) -> bool: - """Verify password against hash (mock implementation).""" - # In production: bcrypt.checkpw(password.encode(), hashed.encode()) - return password == hashed - - def _is_rate_limited(self, user_id: str) -> bool: - """Check if user is rate limited.""" - if user_id not in self._rate_limits: - return False - - # Clean old attempts - window = timedelta(hours=self.RATE_LIMIT_WINDOW_HOURS) - cutoff = datetime.now() - window - self._rate_limits[user_id] = [ - t for t in self._rate_limits[user_id] if t > cutoff - ] - - return len(self._rate_limits[user_id]) >= self.MAX_HISTORY - - def _record_attempt(self, user_id: str): - """Record password change attempt.""" - if user_id not in self._rate_limits: - self._rate_limits[user_id] = [] - self._rate_limits[user_id].append(datetime.now()) - - def _get_history(self, user_id: str) -> PasswordHistory: - """Get or create password history for user.""" - if user_id not in self._password_history: - self._password_history[user_id] = PasswordHistory(user_id) - return self._password_history[user_id] - - def change_password( - self, - user_id: str, - current_password: str, - new_password: str, - confirm_password: str - ) -> tuple[bool, int, str | None]: - """ - Change user's password. - - Returns: - (success, status_code, error_message) - """ - # Check user exists - if user_id not in self._users: - return False, 404, "Usuario no encontrado" - - # Check rate limit - if self._is_rate_limited(user_id): - return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora" - - # Record attempt - self._record_attempt(user_id) - - user = self._users[user_id] - - # Verify current password - if not self._verify_password(current_password, user["password_hash"]): - return False, 401, "La contraseña actual es incorrecta" - - # Verify passwords match - if new_password != confirm_password: - return False, 400, "Las contraseñas no coinciden" - - # Check password history - history = self._get_history(user_id) - if history.is_reused(new_password): - return False, 400, "La nueva contraseña no puede ser igual a la anterior" - - # Validate password strength - is_valid, strength_error = self.validate_password_strength(new_password) - if not is_valid: - return False, 400, strength_error - - # Change password - hashed_new = self._hash_password(new_password) - history.add(user["password_hash"]) # Save old hash before changing - user["password_hash"] = hashed_new - - # Invalidate all sessions - self._sessions[user_id] = [] - - return True, 200, None - - def validate_password_strength(self, password: str) -> tuple[bool, str]: - """ - Validate password meets security requirements. - - Returns: - (is_valid, error_message) - """ - if len(password) < 8: - return False, "La contraseña debe tener al menos 8 caracteres" - if len(password) > 128: - return False, "La contraseña debe tener máximo 128 caracteres" - if not re.search(r'[A-Z]', password): - return False, "La contraseña debe contener al menos una mayúscula" - if not re.search(r'[a-z]', password): - return False, "La contraseña debe contener al menos una minúscula" - if not re.search(r'\d', password): - return False, "La contraseña debe contener al menos un número" - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password): - return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - return True, "" - - -# Singleton instance -password_service = PasswordService() \ No newline at end of file diff --git a/src/services/profile_service.py b/src/services/profile_service.py deleted file mode 100644 index 59e324b..0000000 --- a/src/services/profile_service.py +++ /dev/null @@ -1,86 +0,0 @@ -"""User Profile Service - main business logic.""" -from datetime import datetime -from typing import Literal - -from src.models.profile import Profile, UpdateProfileRequest - - -class ProfileService: - """Service for managing user profiles.""" - - def __init__(self): - self._profiles: dict[str, Profile] = {} - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock data for testing.""" - self._profiles = { - "user-123": Profile( - id="user-123", - name="Juan Pérez", - avatar_url="https://cdn.example.com/avatar-123.jpg", - language="es" - ), - "user-456": Profile( - id="user-456", - name="María García", - avatar_url="https://cdn.example.com/avatar-456.jpg", - language="en" - ) - } - - def get_profile(self, user_id: str) -> tuple[Profile | None, int, str | None]: - """ - Get user profile by ID. - - Returns: - tuple: (profile, status_code, error_message) - """ - if user_id not in self._profiles: - return None, 404, "Usuario no encontrado" - return self._profiles[user_id], 200, None - - def update_profile( - self, - user_id: str, - request: UpdateProfileRequest - ) -> tuple[Profile | None, int, str | None]: - """ - Update user profile. - - Returns: - tuple: (profile, status_code, error_message) - """ - if user_id not in self._profiles: - return None, 404, "Usuario no encontrado" - - profile = self._profiles[user_id] - - # Update fields - if request.name is not None: - profile.name = request.name - - if request.avatar_url is not None: - profile.avatar_url = request.avatar_url - - if request.language is not None: - profile.language = request.language - - profile.updated_at = datetime.now() - - return profile, 200, None - - def create_profile(self, user_id: str, name: str, avatar_url: str = "", language: str = "en") -> Profile: - """Create a new profile.""" - profile = Profile( - id=user_id, - name=name, - avatar_url=avatar_url, - language=language - ) - self._profiles[user_id] = profile - return profile - - -# Singleton instance -profile_service = ProfileService() \ No newline at end of file diff --git a/src/services/session_store.py b/src/services/session_store.py deleted file mode 100644 index cb76414..0000000 --- a/src/services/session_store.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Session store for managing active sessions in Redis.""" -import json -from typing import Optional -from dataclasses import dataclass, asdict -from datetime import datetime -import hashlib - - -@dataclass -class Session: - """Session data structure.""" - user_id: str - token_id: str - created_at: str - ip_address: Optional[str] = None - user_agent: Optional[str] = None - last_activity: Optional[str] = None - - -class SessionStore: - """ - In-memory session store (simulating Redis for testing). - - In production, replace with Redis: - - session:{user_id}:{token_id} -> JSON session metadata - - user_sessions:{user_id} -> SET of active token_ids - """ - - def __init__(self): - self._sessions = {} # token_id -> Session - self._user_sessions = {} # user_id -> set of token_ids - self._revoked_tokens = set() # token_ids that are revoked - - def create_session( - self, - user_id: str, - token_id: str, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None - ) -> bool: - """ - Store a new active session. - """ - session = Session( - user_id=user_id, - token_id=token_id, - created_at=datetime.utcnow().isoformat(), - ip_address=ip_address, - user_agent=user_agent, - last_activity=datetime.utcnow().isoformat() - ) - - self._sessions[token_id] = session - - if user_id not in self._user_sessions: - self._user_sessions[user_id] = set() - self._user_sessions[user_id].add(token_id) - - # Limit sessions per user - max_sessions = 10 - if len(self._user_sessions[user_id]) > max_sessions: - oldest = min(self._user_sessions[user_id]) - self.revoke_session(oldest) - - return True - - def get_session(self, token_id: str) -> Optional[Session]: - """ - Get session by token ID. - Returns None if not found or revoked. - """ - if token_id in self._revoked_tokens: - return None - return self._sessions.get(token_id) - - def revoke_session(self, token_id: str) -> bool: - """ - Revoke a specific session. - """ - session = self._sessions.get(token_id) - if session: - self._revoked_tokens.add(token_id) - del self._sessions[token_id] - - if session.user_id in self._user_sessions: - self._user_sessions[session.user_id].discard(token_id) - - return True - return False - - def revoke_all_user_sessions(self, user_id: str) -> int: - """ - Revoke all sessions for a user. - Returns count of revoked sessions. - """ - if user_id not in self._user_sessions: - return 0 - - count = 0 - for token_id in list(self._user_sessions[user_id]): - self.revoke_session(token_id) - count += 1 - - return count - - def get_user_session_count(self, user_id: str) -> int: - """Count active sessions for a user.""" - return len(self._user_sessions.get(user_id, set())) - - def is_session_valid(self, token_id: str) -> bool: - """Check if session is valid (exists and not revoked).""" - return token_id in self._sessions and token_id not in self._revoked_tokens - - def cleanup_expired(self) -> int: - """ - Remove expired sessions. - Returns count of cleaned sessions. - """ - # In real Redis, use TTL and SCAN for this - return 0 - - def clear_all(self) -> None: - """Clear all sessions (for testing).""" - self._sessions.clear() - self._user_sessions.clear() - self._revoked_tokens.clear() - - -# Singleton instance -session_store = SessionStore() \ No newline at end of file diff --git a/src/services/token_service.py b/src/services/token_service.py deleted file mode 100644 index c1a5a30..0000000 --- a/src/services/token_service.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Token service for JWT generation and validation.""" -import os -import uuid -from datetime import datetime, timezone, timedelta -from typing import Optional - -import jwt -from jwt.exceptions import InvalidTokenError, ExpiredSignatureError - -from src.models.auth import TokenPayload - - -class TokenService: - """Service for JWT token operations.""" - - def __init__(self): - self.secret_key = os.getenv("JWT_SECRET", "dev-secret-key-change-in-prod") - self.algorithm = "HS256" - self.access_token_expire = 900 # 15 minutes - self.refresh_token_expire = 604800 # 7 days - - def _now_timestamp(self) -> int: - """Get current UTC timestamp (uses time.time() for accuracy).""" - import time - return int(time.time()) - - def create_access_token(self, user_id: str, email: str, role: str = "user") -> tuple[str, str]: - """ - Generate a new JWT access token. - - Returns: - Tuple of (token_string, token_id/jti) - """ - token_id = str(uuid.uuid4()) - now = self._now_timestamp() - exp = now + self.access_token_expire - - payload = { - "sub": user_id, - "email": email, - "role": role, - "iat": now, - "exp": exp, - "jti": token_id - } - - token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) - return token, token_id - - def create_refresh_token(self, user_id: str, email: str, role: str = "user") -> str: - """ - Generate a new refresh token. - """ - now = self._now_timestamp() - exp = now + self.refresh_token_expire - - payload = { - "sub": user_id, - "email": email, - "role": role, - "iat": now, - "exp": exp, - "jti": str(uuid.uuid4()), - "type": "refresh" - } - - token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) - return token - - def verify_token(self, token: str) -> TokenPayload: - """ - Validate and decode a JWT token. - - Returns: - TokenPayload if valid - - Raises: - InvalidTokenError: If token is invalid - ExpiredSignatureError: If token has expired - """ - try: - payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) - return TokenPayload(**payload) - except jwt.DecodeError as e: - raise InvalidTokenError(f"Invalid token: {str(e)}") - - def decode_token_unsafe(self, token: str) -> Optional[dict]: - """ - Decode token without validation (for debugging). - """ - try: - return jwt.decode(token, options={"verify_signature": False}) - except Exception: - return None - - def is_token_expired(self, token: str) -> bool: - """Check if token is expired without raising exception.""" - try: - self.verify_token(token) - return False - except ExpiredSignatureError: - return True - except InvalidTokenError: - return True - - def get_token_claims(self, token: str) -> Optional[dict]: - """Get token claims without full validation.""" - try: - payload = jwt.decode( - token, - self.secret_key, - algorithms=[self.algorithm], - options={"verify_exp": False} - ) - return payload - except Exception: - return None - - -# Singleton instance -token_service = TokenService() \ No newline at end of file diff --git a/src/ui/change-password.html b/src/ui/change-password.html deleted file mode 100644 index d728a9b..0000000 --- a/src/ui/change-password.html +++ /dev/null @@ -1,359 +0,0 @@ - - - - - - Cambiar Contraseña - ARNES - - - -
-

🔑 Cambiar Contraseña

-

Actualiza tu contraseña de seguridad

- -
- -
-
- -
- - -
-
- -
- -
- - -
-
-
○ Mínimo 8 caracteres
-
○ Al menos 1 mayúscula
-
○ Al menos 1 minúscula
-
○ Al menos 1 número
-
○ Al menos 1 carácter especial
-
-
- -
- -
- - -
-
- - -
- - -
- - - - \ No newline at end of file diff --git a/src/ui/dashboard.html b/src/ui/dashboard.html deleted file mode 100644 index 86925ac..0000000 --- a/src/ui/dashboard.html +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - Dashboard - ARNES - - - -
-

📊 ARNES Dashboard

-
- - -
-
- -
-
-
-

🔐 Estado de Autenticación

-
-

Token activo: Verificando...

-
- -
-
- -
-

🔑 Operaciones

-
-

Acciones disponibles para el usuario autenticado:

- -
-
- -
-

🧪 Debug Info

-
-

API Base:

-

Token expiry:

-
- Access Token:
- - -
-
-
-
- -
-
- - - - \ No newline at end of file diff --git a/src/ui/login.html b/src/ui/login.html deleted file mode 100644 index 07000a6..0000000 --- a/src/ui/login.html +++ /dev/null @@ -1,325 +0,0 @@ - - - - - - Iniciar Sesión - ARNES - - - - - -
✅ ¡Bienvenido!
- - - - \ No newline at end of file diff --git a/starter-pack/README.md b/starter-pack/README.md index b5776bc..ae0c8f2 100644 --- a/starter-pack/README.md +++ b/starter-pack/README.md @@ -4,15 +4,15 @@ Este pack sirve para arrancar ARNES en 2 escenarios: ## A) Proyecto nuevo (greenfield) 1. Crea repo vacío. -2. Copia el template ARNES. +2. Instala el core ARNES desde otro repo fuente (`scripts/install_into_repo.sh`). 3. Ajusta `backlog/features.json` (`project`, `description`). -4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature. +4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature (`type=chore`). 5. Ejecuta: - `./scripts/verify.sh` - `python3 scripts/agent_status.py show` ## B) Proyecto ya empezado (brownfield) -1. Copia **solo** carpetas core ARNES: `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +1. Instala **solo** el core ARNES dentro del repo existente. 2. Mantén tu código actual intacto. 3. Añade checks del dominio en `scripts/verify.local.sh`. 4. Define features reales del proyecto en `backlog/features.json`. @@ -22,5 +22,7 @@ Este pack sirve para arrancar ARNES en 2 escenarios: ## Reglas mínimas - 1 sola feature en `in_progress`. +- Tipos válidos: `feature`, `fix`, `bug`, `chore`. - `done` requiere gates: `review/security/qa`. +- `done` requiere commit+push final del ticket. - Evidencia en `work/artifacts//`. diff --git a/starter-pack/backlog.features.bootstrap.json b/starter-pack/backlog.features.bootstrap.json index 712c52c..2cddcf6 100644 --- a/starter-pack/backlog.features.bootstrap.json +++ b/starter-pack/backlog.features.bootstrap.json @@ -1,11 +1,25 @@ { "id": "F-001", - "title": "Bootstrap de proyecto con ARNES", - "description": "Configurar pipeline SDD en este repositorio y validar primer ciclo completo.", + "type": "chore", + "title": "Bootstrap ARNES on project", + "problem": "Need base workflow and control", + "goal": "Make ARNES ready on this repo", + "scope_in": [ + "Harness setup", + "Runtime status", + "First verify cycle" + ], + "scope_out": [ + "Business feature work", + "Product redesign" + ], + "priority": "med", + "risk": "low", + "description": "Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.", "acceptance": [ - "verify.sh en verde", - "runtime-status operativo", - "primera feature cerrada con gates" + "verify.sh is green", + "runtime status works", + "first feature closes with gates" ], "status": "pending", "created_at": "YYYY-MM-DD", diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index a013d54..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests init.""" \ No newline at end of file diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 77249a1ef974b939da0a25e361527120a96f10ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 173 zcmey&%ge<81g)!nXNm#o#~=<2FhUuhIe?6*48aUV4C#!TOjQD*d6^{&C8@}zQ`1q9!pF!Gg>FQ_X=ceixWo8$px1>` t$H#+o$H(gxRNmsS$<0qG%}KQ@Vgu?1*<1{=^#d~_BjYUwu_6{A2LNNXEbssT diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index a013d54..0000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests init.""" \ No newline at end of file diff --git a/tests/unit/__pycache__/__init__.cpython-313.pyc b/tests/unit/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 1375c6d2dca80432f81ea9388e45b27498c621a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmey&%ge<81g)!nXNm#o#~=<2FhUuhIe?6*48aUV4C#!TOjQD*d6^{&C8@}zQ`1q9!pF!Gg8R=)_=ceixWo8$px1>` xmjdzDRsJz8tlbfGXnv-f*#0E41WPLHn>JQ9}jEuJ!#EMve8~|_gF600J diff --git a/tests/unit/__pycache__/test_auth.cpython-313.pyc b/tests/unit/__pycache__/test_auth.cpython-313.pyc deleted file mode 100644 index a6a5c3b40b5b8dc7cb1e1e14db7cce801657a667..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14126 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 BTfYDR diff --git a/tests/unit/__pycache__/test_password.cpython-313.pyc b/tests/unit/__pycache__/test_password.cpython-313.pyc deleted file mode 100644 index de104e748c6317814c3246f72304da2425028870..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9586 zcmdT~U2GiH6~42-v+MN_Hcsp%4rAvBFNxR0j>Jumr@QCGDHbW~_;we%eTs!n3ed#%OX7(?$ zn?*!GM%LMxxxaJIch3FJx!!1Oj1sv1{Fnc!+d2vP5+B?v=G<&t;|RG-L_&$kiM}Ho z<=C%}`X;y&A-8(*_e7+4@7{^rgwL~XgIlBc`z_XNERO2lO@xQwd-1CJ>$A%Rdp&r{ z!+Q9SL|%x}s6aYM4-o@#A_gb;6PxUsrE--&T2mH z6p408+Ubh@IEz6x4#VSRGC@H7g!)8|`b8fNi2gXCLHuAE5`!SaB771oNf=~AsRe6s;(u|?woLfs0>`v zjUEt}$;*W@cR|KUrtof>EO28BK8g*ju(J>goE0#X%#?xE1S{1)qi2{fCYH933E3L1(zK5ISX`C&;`hV6fW)gas&S)rvbR zR0^vm875V-Pk0s<9ko}@X&YOwF0Ih~U;3x0~rW$~&ZvF27#p;V^^e!L(E6j#*R zj%u;Xi+#i7q|(V*k9#Yop0+J8im?~OdGfFL)^~Qly?Z4-vKSxvD7h4W@j~+)zskqn zY`)Mu&)?v?;eLhhSmZnA+u!vq^V{eH(D3`EU(q*y7yTMDIyDt4m@~2_>=)8AGL_Tt z!bs*rsNX%^`F1%23fFW4%bjAy7+bK_O&>(fIJ$gR&KQct+lqZ&qnmL(%T&0TslhcT z$<%d;O~LA`p|v}!qr)}O@+%e9yA(g-D9TYz#|^#*)z)~S@v}{R@8y>Hp&FW+gFays z0Cx0V+!-I;dr@XG+io;Vm(%ma!Ollg9&VS zS#pWX^|IvtYwgQ?>b}-ZMn6-jo8u#*_|mZxhpOmC)xN%N3ZvAmn?j2O=!P$Bc;xAW-MO}M;0v&_S%vCVL0_?P0fg;->)W^r)%#&s&yDLYaNqUb z_%c6uf56>?LZT}&W|g!GzNWx2l2(JO62N;7f*u0=;`$ZjAA;gkud0x)JG_w7Lc#&8 z!8#pLX(Mz)p33Q4iXX4BPdxhGGt2w{Q_cd%Nbp#q$>JqRle3B>Sy4&K>XW$)-p3>f z39nPqEJ;%;HO!2v0mFjgup~|DX-T3@Fc%s}A)wff;t>?PP$W?dfUw#nVAry#rX|@l zsrpI|mPL{l2%8O~%@jieCXk*ReIQ;Zp9gx^16(k%9ts4vug3zxee1DcFty$k2=2#6 zCiZ>>cQ)u)`!qByTyC(nF$S;hMZgGQW1|?OP1B9Acx|QXbK=#TPryj|omf&4G&&KU zpu=VdYoTk#Hwqpp4+Bw5Z&1uz*a4%xmZ%o1(_?aW;5UqIFp9X-QZa*qRCwcWAif@H z5PhOw^ba9Df-48T%(iO&Dm1g6pf6CQna~SJH$R18#u7n0PHeVY79u&? z4y6|0khGpc$0adbAi>F~Sh6n=K zWA~1$Tdr(b>3(vt`^nYVrqx)d{kIVb_Cy!?t#je|_O%#^G_n9Oe1jK?0pg~2+ivhX z*mLJ1-#NcynHOpxcB4sizu27CBpeV3?sA7E{lF1fFZC9!Wjx&TuA3A z{e%4T{-1=zTV zGHkrigQ#IdN&bOD>eQ5im_mJ}_8;iJc@cqwk>;pulp@CvG##D+HR`0IjnQM+i3dV# zT2bji-04^h<8^_p2ire17X{thH`^E%Q5SsOLWQ^N)J^ zc|3d2V7or68YVhgC?Z+#do<<0Gx^idF2X9_C1l*0^b>*pLet!*2{2RvpCML_0i_k~q z<$a6*yb_N90(R|hgIS6};FiTB2jmSkwgK1m3sY3j3NrvLot`Uk4GcxhGvpCHvpHo_ z!GkYBokp4<3mKpc8pM;TPF>*Q@bO+dfj3v63V=&;X7V+-LRq_C!jQ&U5a-ES z2q|Oi0-PmmU*xx!GRs?$U?jTMM0$4C$vkWC8g>SJ|A#<6J@yRTu_3S^8!cdxrRQtK zv!*t3xSJ4`8Fv&2^$VBR2^0%l0%Cz@2;?Ymx(32PT7MsK?pW-DfpnV|sKPSJv+SOs zjab4%pcjv$X)!!r5+Y6WaLl@kIX#FjA(N_RRTGJcJcXHxAz?~ZGs>g@0l1Q#HI3v= z7I7oDrN|0xkRoeQ|BC*u?U0g{4HbMGvRHJMx-pgrSwV_~nFU8)7=^fr#X+1zpT}Nb zK*93nI2`89oy0ji43==J5}T^T<|z$fhJa1b+FR8%)@XGJMloIoah}`?k=D%@PyeA~ zZe%sq^5*D;(Un;5Vyt(yCBEA7@M=pJ{QWl)Y;5|EpR~r;TJJ4{tL4HlYG)9>1vi&T zKOEz{SnU|c1AL6L z#w?59{TW5G{L_kAnLn|1vc21FB9%G$LJw%eD++4u+ zuUjEXpU*GykKWbiwt!Ezo#`Lj=1;ye@%F?@*WhB;;7V6&u`Bgq^rNSjx}IC=Jp5_* zGC#^TbBd?NV~J*pIYhb-MGD1!6bDdX=7=6aaU8`-6v#2?1PTd-f zx;lJi_}ah+srOSK48K48(ZDCEk5iuve?0t|`0~=hiKW4xE^YhSQk!&x{QOJO{7-V= zR;ZsFoIkv>rEhUd-z@?Uw8I0*m% diff --git a/tests/unit/__pycache__/test_profile.cpython-313.pyc b/tests/unit/__pycache__/test_profile.cpython-313.pyc deleted file mode 100644 index 0fe74ef491c4ad856ecdf2f9e9b62c4b28974428..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9497 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 W_G^6jwL?EU^ozqc{Dkjdi2Mgbtn#A( diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py deleted file mode 100644 index d1d02cf..0000000 --- a/tests/unit/test_auth.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Unit tests for authentication service.""" -import unittest -from unittest.mock import MagicMock, patch - -from src.models.auth import LoginRequest, TokenPayload -from src.services.auth_service import ( - AuthService, auth_service, - InvalidCredentialsError, AccountLockedError, InvalidTokenError -) -from src.services.token_service import TokenService -from src.services.session_store import SessionStore - - -class TestAuthService(unittest.TestCase): - """Test cases for AuthService.""" - - def setUp(self): - """Set up test fixtures.""" - self.mock_token_service = MagicMock(spec=TokenService) - self.mock_session_store = MagicMock(spec=SessionStore) - - self.auth_service = AuthService( - token_svc=self.mock_token_service, - session_store=self.mock_session_store - ) - - # Default mock returns - self.mock_token_service.create_access_token.return_value = ("access_token_123", "token_id_123") - self.mock_token_service.create_refresh_token.return_value = "refresh_token_456" - - def test_login_success(self): - """Test successful login.""" - request = LoginRequest(email="alice@example.com", password="SecurePass123!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": True - } - - with patch.object(self.auth_service, '_verify_password', return_value=True): - result = self.auth_service.login(request, "127.0.0.1") - - self.assertTrue(result.success) - self.assertEqual(result.access_token, "access_token_123") - self.assertEqual(result.refresh_token, "refresh_token_456") - self.mock_session_store.create_session.assert_called_once() - - def test_login_invalid_credentials(self): - """Test login with invalid credentials.""" - request = LoginRequest(email="alice@example.com", password="WrongPassword!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": True - } - - with patch.object(self.auth_service, '_verify_password', return_value=False): - with self.assertRaises(InvalidCredentialsError): - self.auth_service.login(request, "127.0.0.1") - - def test_login_nonexistent_user(self): - """Test login with nonexistent user.""" - request = LoginRequest(email="nonexistent@test.com", password="AnyPassword123!") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - with self.assertRaises(InvalidCredentialsError): - self.auth_service.login(request, "127.0.0.1") - - def test_login_inactive_account(self): - """Test login with inactive account.""" - request = LoginRequest(email="alice@example.com", password="SecurePass123!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": False - } - - with self.assertRaises(InvalidCredentialsError) as ctx: - self.auth_service.login(request, "127.0.0.1") - - self.assertIn("desactivada", str(ctx.exception)) - - def test_logout_success(self): - """Test successful logout.""" - result = self.auth_service.logout("token_id_123", "user-001") - - self.assertTrue(result) - self.mock_session_store.revoke_session.assert_called_once_with("token_id_123") - - def test_logout_all_sessions(self): - """Test logout all sessions.""" - self.mock_session_store.revoke_all_user_sessions.return_value = 3 - - result = self.auth_service.logout_all("user-001") - - self.assertEqual(result, 3) - self.mock_session_store.revoke_all_user_sessions.assert_called_once_with("user-001") - - def test_refresh_success(self): - """Test successful token refresh.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123", - type="refresh" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = True - self.mock_token_service.create_access_token.return_value = ("new_access_token", "new_token_id") - - result = self.auth_service.refresh("valid_refresh_token") - - self.assertTrue(result.success) - self.assertEqual(result.access_token, "new_access_token") - - def test_refresh_invalid_token(self): - """Test refresh with invalid token.""" - self.mock_token_service.verify_token.side_effect = InvalidTokenError("Invalid token") - - with self.assertRaises(InvalidTokenError): - self.auth_service.refresh("invalid_token") - - def test_refresh_revoked_session(self): - """Test refresh with revoked session.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123", - type="refresh" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = False - - with self.assertRaises(InvalidTokenError): - self.auth_service.refresh("valid_refresh_token") - - def test_validate_token_valid(self): - """Test token validation with valid token.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = True - - is_valid, payload, error = self.auth_service.validate_token("valid_token") - - self.assertTrue(is_valid) - self.assertEqual(payload.sub, "user-001") - self.assertIsNone(error) - - def test_validate_token_revoked(self): - """Test token validation with revoked session.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = False - - is_valid, payload, error = self.auth_service.validate_token("valid_token") - - self.assertFalse(is_valid) - self.assertEqual(error, "Session revoked") - - def test_validate_token_expired(self): - """Test token validation with expired token.""" - from jwt.exceptions import ExpiredSignatureError - - self.mock_token_service.verify_token.side_effect = ExpiredSignatureError("Token expired") - - is_valid, payload, error = self.auth_service.validate_token("expired_token") - - self.assertFalse(is_valid) - self.assertIn("expired", error.lower()) - - -class TestRateLimiting(unittest.TestCase): - """Test rate limiting functionality.""" - - def setUp(self): - self.auth_service = AuthService() - - def test_rate_limit_allows_first_attempts(self): - """Test rate limit allows initial attempts.""" - import time - ip = "10.0.0.1" - - # Clear any existing rate limit data - self.auth_service._rate_limit_store = {ip: []} - - request = LoginRequest(email="test@test.com", password="wrong") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - # First 9 attempts should raise InvalidCredentialsError (not locked) - for i in range(9): - try: - self.auth_service.login(request, ip) - except InvalidCredentialsError: - pass # Expected - user doesn't exist - except AccountLockedError: - self.fail("Should not be locked after 9 attempts") - - # 10th attempt should still raise InvalidCredentialsError (not locked yet) - try: - self.auth_service.login(request, ip) - except InvalidCredentialsError: - pass # Still not locked - except AccountLockedError: - self.fail("Should not be locked after 10 attempts") - - # Verify rate limit counter is at 10 - self.assertEqual(len(self.auth_service._rate_limit_store[ip]), 10) - - def test_rate_limit_blocks_after_threshold(self): - """Test rate limit blocks after threshold.""" - import time - ip = "10.0.0.2" - - # Pre-fill rate limit - now = time.time() - self.auth_service._rate_limit_store[ip] = [now - 50] * 10 - - request = LoginRequest(email="test@test.com", password="wrong") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - with self.assertRaises(AccountLockedError): - self.auth_service.login(request, ip) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/unit/test_password.py b/tests/unit/test_password.py deleted file mode 100644 index 9f4c2e8..0000000 --- a/tests/unit/test_password.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Unit tests for password service.""" -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -import unittest -from src.services.password_service import PasswordService - - -class TestPasswordValidator(unittest.TestCase): - """Tests for password validation.""" - - def setUp(self): - self.service = PasswordService() - - def test_valid_password_all_requirements(self): - """Test password with all requirements met.""" - is_valid, error = self.service.validate_password_strength("Password123!") - self.assertTrue(is_valid) - self.assertEqual(error, "") - - def test_password_too_short(self): - """Test password shorter than 8 characters.""" - is_valid, error = self.service.validate_password_strength("Pass1!") - self.assertFalse(is_valid) - self.assertIn("al menos 8 caracteres", error) - - def test_password_too_long(self): - """Test password longer than 128 characters.""" - long_pass = "A" * 129 + "a1!" - is_valid, error = self.service.validate_password_strength(long_pass) - self.assertFalse(is_valid) - self.assertIn("máximo 128 caracteres", error) - - def test_password_no_uppercase(self): - """Test password without uppercase letter.""" - is_valid, error = self.service.validate_password_strength("password123!") - self.assertFalse(is_valid) - self.assertIn("al menos una mayúscula", error) - - def test_password_no_lowercase(self): - """Test password without lowercase letter.""" - is_valid, error = self.service.validate_password_strength("PASSWORD123!") - self.assertFalse(is_valid) - self.assertIn("al menos una minúscula", error) - - def test_password_no_number(self): - """Test password without number.""" - is_valid, error = self.service.validate_password_strength("PasswordABC!") - self.assertFalse(is_valid) - self.assertIn("al menos un número", error) - - def test_password_no_special_char(self): - """Test password without special character.""" - is_valid, error = self.service.validate_password_strength("Password123") - self.assertFalse(is_valid) - self.assertIn("carácter especial", error) - - -class TestPasswordService(unittest.TestCase): - """Tests for password service operations.""" - - def setUp(self): - self.service = PasswordService() - - def test_change_password_success(self): - """Test successful password change.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertTrue(success) - self.assertEqual(status, 200) - self.assertIsNone(error) - - def test_change_password_wrong_current(self): - """Test password change with wrong current password.""" - success, status, error = self.service.change_password( - "user-123", - "WrongPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 401) - self.assertEqual(error, "La contraseña actual es incorrecta") - - def test_change_password_mismatch(self): - """Test password change with mismatching passwords.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "DifferentPass789!" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - self.assertEqual(error, "Las contraseñas no coinciden") - - def test_change_password_weak(self): - """Test password change with weak password.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "weak", - "weak" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - - def test_change_password_nonexistent_user(self): - """Test password change for nonexistent user.""" - success, status, error = self.service.change_password( - "nonexistent", - "AnyPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 404) - self.assertEqual(error, "Usuario no encontrado") - - def test_change_password_reuse_history(self): - """Test password change with password from history.""" - # First change - self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - # Try to reuse current (which is now in history) - success, status, error = self.service.change_password( - "user-123", - "NewPass456@", - "OldPass123!", - "OldPass123!" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - self.assertIn("no puede ser igual a la anterior", error) - - def test_rate_limit_after_5_attempts(self): - """Test rate limiting after 5 failed attempts.""" - # Make 5 failed attempts - for _ in range(5): - self.service.change_password( - "user-123", - "WrongPass123!", - "NewPass456@", - "NewPass456@" - ) - - # 6th attempt should be rate limited - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 429) - self.assertIn("Demasiados intentos", error) - - def test_sessions_invalidated_after_change(self): - """Test that sessions are invalidated after password change.""" - # Add some sessions - self.service._sessions["user-123"] = ["token1", "token2", "token3"] - - # Change password - self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - - # Sessions should be cleared - self.assertEqual(len(self.service._sessions.get("user-123", [])), 0) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py deleted file mode 100644 index 0c94c4b..0000000 --- a/tests/unit/test_profile.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Unit tests for profile service using unittest.""" -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -import unittest -from src.models.profile import Profile, UpdateProfileRequest -from src.services.profile_service import ProfileService - - -class TestProfileModel(unittest.TestCase): - """Tests for Profile model.""" - - def test_profile_creation(self): - """Test creating a profile.""" - profile = Profile(id="user-1", name="Juan Pérez", language="es") - self.assertEqual(profile.id, "user-1") - self.assertEqual(profile.name, "Juan Pérez") - self.assertEqual(profile.language, "es") - - def test_profile_name_validation_letters(self): - """Test valid name with letters only.""" - profile = Profile(id="user-1", name="José García") - self.assertEqual(profile.name, "José García") - - def test_profile_name_validation_invalid_chars(self): - """Test invalid name with special characters.""" - with self.assertRaises(ValueError) as ctx: - Profile(id="user-1", name="Juan@123!") - self.assertIn("Nombre inválido", str(ctx.exception)) - - def test_profile_avatar_url_valid(self): - """Test valid avatar URL.""" - profile = Profile(id="user-1", name="Test", avatar_url="https://cdn.example.com/img.jpg") - self.assertEqual(profile.avatar_url, "https://cdn.example.com/img.jpg") - - def test_profile_avatar_url_invalid_protocol(self): - """Test invalid avatar URL protocol.""" - with self.assertRaises(ValueError) as ctx: - Profile(id="user-1", name="Test", avatar_url="ftp://malicious.com/file.jpg") - self.assertIn("Solo se permiten", str(ctx.exception)) - - def test_profile_language_values(self): - """Test valid language values.""" - for lang in ["en", "es", "fr", "de"]: - profile = Profile(id="user-1", name="Test", language=lang) - self.assertEqual(profile.language, lang) - - -class TestUpdateProfileRequest(unittest.TestCase): - """Tests for UpdateProfileRequest model.""" - - def test_partial_update_name_only(self): - """Test updating only name.""" - request = UpdateProfileRequest(name="Pedro") - self.assertEqual(request.name, "Pedro") - self.assertIsNone(request.avatar_url) - self.assertIsNone(request.language) - - def test_partial_update_all_fields(self): - """Test updating all fields.""" - request = UpdateProfileRequest( - name="Pedro", - avatar_url="https://cdn.example.com/new.jpg", - language="en" - ) - self.assertEqual(request.name, "Pedro") - self.assertEqual(request.avatar_url, "https://cdn.example.com/new.jpg") - self.assertEqual(request.language, "en") - - -class TestProfileService(unittest.TestCase): - """Tests for ProfileService.""" - - def setUp(self): - """Create a fresh service instance.""" - self.service = ProfileService() - - def test_get_existing_profile(self): - """Test getting an existing profile.""" - profile, status, error = self.service.get_profile("user-123") - self.assertEqual(status, 200) - self.assertIsNotNone(profile) - self.assertEqual(profile.id, "user-123") - self.assertIsNone(error) - - def test_get_nonexistent_profile(self): - """Test getting a nonexistent profile.""" - profile, status, error = self.service.get_profile("nonexistent") - self.assertEqual(status, 404) - self.assertIsNone(profile) - self.assertEqual(error, "Usuario no encontrado") - - def test_update_profile_name(self): - """Test updating profile name.""" - request = UpdateProfileRequest(name="Pedro") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.name, "Pedro") - - def test_update_profile_avatar(self): - """Test updating profile avatar.""" - request = UpdateProfileRequest(avatar_url="https://cdn.example.com/new.jpg") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.avatar_url, "https://cdn.example.com/new.jpg") - - def test_update_profile_language(self): - """Test updating profile language.""" - request = UpdateProfileRequest(language="fr") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.language, "fr") - - def test_update_nonexistent_profile(self): - """Test updating a nonexistent profile.""" - request = UpdateProfileRequest(name="Test") - profile, status, error = self.service.update_profile("nonexistent", request) - self.assertEqual(status, 404) - self.assertEqual(error, "Usuario no encontrado") - - def test_create_profile(self): - """Test creating a new profile.""" - profile = self.service.create_profile("user-new", "New User", "https://cdn.com/img.jpg", "es") - self.assertEqual(profile.id, "user-new") - self.assertEqual(profile.name, "New User") - self.assertEqual(profile.language, "es") - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file