refactor: make ARNES external-repo based with ticket publish flow
This commit is contained in:
@@ -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
|
## Example
|
||||||
- Stack: FastAPI + PostgreSQL
|
- App dir: `project/`
|
||||||
- Deploy: Kubernetes
|
- Deploy target: staging Kubernetes cluster
|
||||||
- Regla extra: toda migración requiere evidencia en `work/artifacts/<id>/db.md`
|
- Extra rule: DB changes require `work/artifacts/<id>/db.md`
|
||||||
- Regla extra: `scripts/verify.local.sh` debe ejecutar `alembic check` y `pytest -m smoke`
|
- Extra rule: `scripts/verify.local.sh` must run smoke tests
|
||||||
|
|||||||
19
AGENTS.md
19
AGENTS.md
@@ -3,12 +3,13 @@
|
|||||||
Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso.
|
Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso.
|
||||||
|
|
||||||
## Arranque obligatorio
|
## Arranque obligatorio
|
||||||
1. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`.
|
1. Usar ARNES dentro de un repo de proyecto real, no dentro del repo fuente de ARNES.
|
||||||
2. Leer `work/current.md`.
|
2. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`.
|
||||||
3. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
|
3. Leer `work/current.md`.
|
||||||
4. Ejecutar `./scripts/verify.sh`.
|
4. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
|
||||||
5. Mostrar estado runtime: `python3 scripts/agent_status.py show`.
|
5. Ejecutar `./scripts/verify.sh`.
|
||||||
6. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
|
6. Mostrar estado runtime: `python3 scripts/agent_status.py show`.
|
||||||
|
7. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
|
||||||
|
|
||||||
## Ticket creation policy
|
## Ticket creation policy
|
||||||
- Tickets are created by `leader` (or `triager`) only.
|
- 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`.
|
- `implementer` nunca marca `done`.
|
||||||
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
|
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
|
||||||
- `done` requiere evidencia de `documenter`: `work/artifacts/<feature_id>/documenter.md`.
|
- `done` requiere evidencia de `documenter`: `work/artifacts/<feature_id>/documenter.md`.
|
||||||
|
- `done` requiere publish final con commit+push del ticket: `work/artifacts/<feature_id>/publish.json`.
|
||||||
- Si `verify.sh` falla, no se cierra la feature.
|
- Si `verify.sh` falla, no se cierra la feature.
|
||||||
|
|
||||||
## Modelo por tarea (token-aware)
|
## 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`
|
- Routing config: `harness/models.profiles.yml`
|
||||||
- Rules: `harness/policies/model-routing.md`
|
- 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/<feature_id>/publish.json`.
|
||||||
|
|
||||||
## Extensión por proyecto (overlay)
|
## Extensión por proyecto (overlay)
|
||||||
- Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual.
|
- Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual.
|
||||||
- Opcional: `scripts/verify.local.sh` para checks de dominio.
|
- Opcional: `scripts/verify.local.sh` para checks de dominio.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# CHECKPOINTS
|
# CHECKPOINTS
|
||||||
|
|
||||||
## C1 — Estructura
|
## 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
|
## C2 — Estado
|
||||||
- [ ] Máximo una feature en `in_progress`.
|
- [ ] Máximo una feature en `in_progress`.
|
||||||
- [ ] Estados válidos en backlog.
|
- [ ] 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`.
|
- [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`.
|
||||||
|
|
||||||
## C3 — Gates
|
## C3 — Gates
|
||||||
@@ -14,6 +16,7 @@
|
|||||||
- [ ] Toda feature `done` tiene `qa.json` aprobado.
|
- [ ] Toda feature `done` tiene `qa.json` aprobado.
|
||||||
- [ ] Toda feature `done` tiene `leader-close.json` válido.
|
- [ ] Toda feature `done` tiene `leader-close.json` válido.
|
||||||
- [ ] Toda feature `done` tiene `documenter.md`.
|
- [ ] Toda feature `done` tiene `documenter.md`.
|
||||||
|
- [ ] Toda feature `done` tiene `publish.json` con commit+push del ticket.
|
||||||
|
|
||||||
## C4 — Verificación
|
## C4 — Verificación
|
||||||
- [ ] `./scripts/verify.sh` termina en OK.
|
- [ ] `./scripts/verify.sh` termina en OK.
|
||||||
|
|||||||
259
HOWTO-FEATURE.md
259
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/<feature_id>/triage.md` (opcional)
|
||||||
|
- `work/artifacts/<feature_id>/architect.md` (opcional)
|
||||||
|
- `work/artifacts/<feature_id>/implementer.md`
|
||||||
|
- `work/artifacts/<feature_id>/reviewer.json`
|
||||||
|
- `work/artifacts/<feature_id>/security.json`
|
||||||
|
- `work/artifacts/<feature_id>/qa.json`
|
||||||
|
- `work/artifacts/<feature_id>/documenter.md`
|
||||||
|
- `work/artifacts/<feature_id>/leader-close.json`
|
||||||
|
- `work/artifacts/<feature_id>/publish.json`
|
||||||
|
|
||||||
## 📋 Flujo general
|
## Ticket style
|
||||||
|
- English caveman
|
||||||
|
- short title
|
||||||
|
- short acceptance bullets
|
||||||
|
- clear scope in/out
|
||||||
|
|
||||||
```
|
## BDD notes
|
||||||
1. Analizar la feature del backlog
|
- Put `.feature` files in `spec/bdd/features/`
|
||||||
↓
|
- Put steps in `features/steps/`
|
||||||
2. Crear SPEC/BBD (architect)
|
- Use tags like `@F-001`, `@smoke`, `@regression`
|
||||||
↓
|
|
||||||
3. Crear/actualizar SDD (architect)
|
|
||||||
↓
|
|
||||||
4. Generar código + tests (implementer)
|
|
||||||
↓
|
|
||||||
5. Review, Security, QA gates
|
|
||||||
↓
|
|
||||||
6. Cerrar feature
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Close rule
|
||||||
|
Feature can be `done` only if:
|
||||||
## Paso 1: Analizar del Backlog
|
- review approved
|
||||||
|
- security approved
|
||||||
Ejemplo: F-002 "Gestión de Perfil de Usuario"
|
- qa approved
|
||||||
|
- documenter evidence exists
|
||||||
```json
|
- publish evidence exists (`publish.json`)
|
||||||
{
|
- `./scripts/verify.sh` is green
|
||||||
"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/<domain>/`
|
|
||||||
- [ ] Steps implementados en `features/steps/`
|
|
||||||
- [ ] Todos los scenarios tienen Given/When/Then
|
|
||||||
- [ ] Tags `@F-XXX` presentes en feature
|
|
||||||
- [ ] SDD/BDD linkeados en artefacto architect
|
|
||||||
|
|||||||
24
HOWTO.md
24
HOWTO.md
@@ -5,13 +5,15 @@
|
|||||||
```bash
|
```bash
|
||||||
mkdir mi-proyecto && cd mi-proyecto
|
mkdir mi-proyecto && cd mi-proyecto
|
||||||
git init
|
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/start.sh
|
||||||
./scripts/verify.sh
|
./scripts/verify.sh
|
||||||
python3 scripts/agent_status.py show
|
python3 scripts/agent_status.py show
|
||||||
```
|
```
|
||||||
|
|
||||||
Después:
|
Después:
|
||||||
|
- Mete tu código dentro de `project/` (o indica otra ruta en el wizard).
|
||||||
- Edita `backlog/features.json` (`project`, `description`).
|
- Edita `backlog/features.json` (`project`, `description`).
|
||||||
- Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`).
|
- Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`).
|
||||||
- Empieza el ciclo SDD (una feature a la vez).
|
- Empieza el ciclo SDD (una feature a la vez).
|
||||||
@@ -20,7 +22,13 @@ Después:
|
|||||||
|
|
||||||
## 2) Proyecto ya empezado (brownfield)
|
## 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/`
|
- `harness/`
|
||||||
- `spec/`
|
- `spec/`
|
||||||
- `backlog/`
|
- `backlog/`
|
||||||
@@ -47,6 +55,17 @@ Crear ticket nuevo (leader/triager, EN caveman):
|
|||||||
python3 scripts/new_ticket.py
|
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:
|
Modelo por tarea:
|
||||||
- Config base en `harness/models.profiles.yml`
|
- Config base en `harness/models.profiles.yml`
|
||||||
- Reglas en `harness/policies/model-routing.md`
|
- Reglas en `harness/policies/model-routing.md`
|
||||||
@@ -54,5 +73,6 @@ Modelo por tarea:
|
|||||||
## Reglas operativas mínimas
|
## Reglas operativas mínimas
|
||||||
- Máximo una feature en `in_progress`.
|
- Máximo una feature en `in_progress`.
|
||||||
- `done` requiere gates `review/security/qa` aprobados.
|
- `done` requiere gates `review/security/qa` aprobados.
|
||||||
|
- `done` requiere publish final con commit+push del ticket.
|
||||||
- Evidencia siempre en `work/artifacts/<feature_id>/`.
|
- Evidencia siempre en `work/artifacts/<feature_id>/`.
|
||||||
- Si `verify.sh` falla, no se cierra la feature.
|
- Si `verify.sh` falla, no se cierra la feature.
|
||||||
|
|||||||
45
Makefile
45
Makefile
@@ -1,19 +1,4 @@
|
|||||||
.PHONY: run run-dev test verify start ticket clean
|
.PHONY: verify start ticket publish install clean help
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
./scripts/verify.sh
|
./scripts/verify.sh
|
||||||
@@ -24,22 +9,24 @@ start:
|
|||||||
ticket:
|
ticket:
|
||||||
python3 scripts/new_ticket.py
|
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:
|
clean:
|
||||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
|
||||||
# Help
|
|
||||||
help:
|
help:
|
||||||
@echo "ARNES UI API - Comandos disponibles:"
|
@echo "ARNES template - commands:"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make run - Arrancar servidor (puerto 8000)"
|
@echo " make verify - Verify harness core"
|
||||||
@echo " make run PORT=8080 - Arrancar en puerto específico"
|
@echo " make start - First-run wizard"
|
||||||
@echo " make run-dev - Arrancar con auto-reload"
|
@echo " make ticket - Create ticket (EN caveman)"
|
||||||
@echo " make test - Ejecutar tests unitarios"
|
@echo " make publish FEATURE_ID=.. - Commit and push one ticket"
|
||||||
@echo " make verify - Verificar harness"
|
@echo " make install TARGET=.. - Install ARNES into external repo"
|
||||||
@echo " make start - Wizard de inicio de proyecto"
|
@echo " make clean - Clean cache files"
|
||||||
@echo " make ticket - Crear ticket (EN caveman)"
|
|
||||||
@echo " make clean - Limpiar cache"
|
|
||||||
@echo ""
|
|
||||||
@echo "URLs:"
|
|
||||||
@echo " http://localhost:8000/ui/login.html"
|
|
||||||
|
|||||||
57
README-UI.md
57
README-UI.md
@@ -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
|
|
||||||
```
|
|
||||||
66
README.md
66
README.md
@@ -1,6 +1,9 @@
|
|||||||
# ARNES Framework (agnóstico) — Diseño v0.1
|
# ARNES Framework (agnóstico) — Diseño v0.1
|
||||||
|
|
||||||
Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad.
|
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.
|
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) ✅
|
7. `qa_gate` (qa) ✅
|
||||||
8. `documentation_gate` (documenter) ✅
|
8. `documentation_gate` (documenter) ✅
|
||||||
9. `close` (leader)
|
9. `close` (leader)
|
||||||
|
10. `publish` (leader) ✅
|
||||||
|
|
||||||
**Regla:** no hay `done` si cualquier gate falla.
|
**Regla:** no hay `done` si cualquier gate falla.
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ Cada agente escribe artefactos en disco:
|
|||||||
- `work/artifacts/<feature>/security.json`
|
- `work/artifacts/<feature>/security.json`
|
||||||
- `work/artifacts/<feature>/qa.json`
|
- `work/artifacts/<feature>/qa.json`
|
||||||
- `work/artifacts/<feature>/leader-close.json`
|
- `work/artifacts/<feature>/leader-close.json`
|
||||||
|
- `work/artifacts/<feature>/publish.json`
|
||||||
|
|
||||||
Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
|
Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
|
||||||
|
|
||||||
@@ -104,29 +109,38 @@ Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|
├── project/ # código real del proyecto
|
||||||
|
│ └── README.md
|
||||||
├── README.md
|
├── README.md
|
||||||
|
├── AGENTS.md
|
||||||
|
├── CHECKPOINTS.md
|
||||||
├── harness/
|
├── harness/
|
||||||
│ ├── agents.matrix.yml
|
│ ├── agents.matrix.yml
|
||||||
│ ├── workflow.stages.yml
|
│ ├── workflow.stages.yml
|
||||||
|
│ ├── models.profiles.yml
|
||||||
│ ├── policies/
|
│ ├── policies/
|
||||||
│ │ ├── security.md
|
|
||||||
│ │ ├── quality.md
|
|
||||||
│ │ └── governance.md
|
|
||||||
│ └── contracts/
|
│ └── contracts/
|
||||||
│ ├── handoff.md
|
|
||||||
│ └── evidence.schema.json
|
|
||||||
├── spec/
|
├── spec/
|
||||||
│ ├── product.md
|
│ ├── product.md
|
||||||
│ ├── tech.md
|
│ ├── tech.md
|
||||||
│ └── acceptance.md
|
│ ├── acceptance.md
|
||||||
|
│ ├── sdd/
|
||||||
|
│ └── bdd/
|
||||||
├── backlog/
|
├── backlog/
|
||||||
│ └── features.json
|
│ └── features.json
|
||||||
├── work/
|
├── work/
|
||||||
│ ├── current.md
|
│ ├── current.md
|
||||||
│ ├── history.md
|
│ ├── history.md
|
||||||
|
│ ├── runtime-status.json
|
||||||
│ └── artifacts/
|
│ └── artifacts/
|
||||||
└── scripts/
|
├── scripts/
|
||||||
└── verify.sh
|
│ ├── 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
|
## Inicio rápido
|
||||||
|
|
||||||
|
- Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo`
|
||||||
- Ejecuta wizard: `./scripts/start.sh`
|
- Ejecuta wizard: `./scripts/start.sh`
|
||||||
- Crear ticket: `python3 scripts/new_ticket.py`
|
- Crear ticket: `python3 scripts/new_ticket.py`
|
||||||
|
- Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
|
||||||
- Guía breve: `HOWTO.md`
|
- Guía breve: `HOWTO.md`
|
||||||
- Starter pack: `starter-pack/README.md`
|
- Starter pack: `starter-pack/README.md`
|
||||||
- Adaptación del template: `TEMPLATE.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`
|
- 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
|
## Próximos pasos sugeridos
|
||||||
|
|
||||||
1. Definir el backlog inicial del proyecto real.
|
1. Instalar/copiar ARNES en un repo de proyecto real distinto del repo fuente.
|
||||||
2. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`).
|
2. Definir el backlog inicial del proyecto real.
|
||||||
3. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`.
|
3. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`).
|
||||||
4. Empezar la primera feature `pending` con pipeline completo.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto
|
# TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto
|
||||||
|
|
||||||
## 1) Clonar y renombrar contexto
|
## 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`).
|
- Ajusta `backlog/features.json` (`project`, `description`).
|
||||||
- Crea primeras features reales en `features[]`.
|
- Crea primeras features reales en `features[]`.
|
||||||
|
|
||||||
@@ -8,16 +9,19 @@
|
|||||||
- Opcional: crea `AGENTS.local.md` con reglas del dominio.
|
- Opcional: crea `AGENTS.local.md` con reglas del dominio.
|
||||||
- Opcional: crea `scripts/verify.local.sh` con checks propios del stack.
|
- Opcional: crea `scripts/verify.local.sh` con checks propios del stack.
|
||||||
- Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`).
|
- 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`.
|
- Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`.
|
||||||
|
|
||||||
## 3) Flujo estándar
|
## 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)
|
1. `./scripts/start.sh` (primer uso)
|
||||||
2. `python3 scripts/new_ticket.py` (leader/triager)
|
2. `python3 scripts/new_ticket.py` (leader/triager)
|
||||||
3. `python3 scripts/agent_status.py show`
|
3. `python3 scripts/agent_status.py show`
|
||||||
4. Seleccionar 1 feature `pending` y pasarla a `in_progress`
|
4. Seleccionar 1 feature `pending` y pasarla a `in_progress`
|
||||||
5. Implementar con artefactos en `work/artifacts/<feature_id>/`
|
5. Implementar con artefactos en `work/artifacts/<feature_id>/`
|
||||||
6. Cerrar solo con gates `review/security/qa` + `documenter` aprobados
|
6. Cerrar con gates `review/security/qa` + `documenter` aprobados
|
||||||
7. `python3 scripts/agent_status.py reset`
|
7. Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
|
||||||
|
8. `python3 scripts/agent_status.py reset`
|
||||||
|
|
||||||
## 4) Contrato de cierre
|
## 4) Contrato de cierre
|
||||||
- `status=done` exige:
|
- `status=done` exige:
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
- `security.json` APPROVED
|
- `security.json` APPROVED
|
||||||
- `qa.json` APPROVED
|
- `qa.json` APPROVED
|
||||||
- `leader-close.json` APPROVED
|
- `leader-close.json` APPROVED
|
||||||
|
- `publish.json` PUBLISHED
|
||||||
- `./scripts/verify.sh` OK
|
- `./scripts/verify.sh` OK
|
||||||
|
|
||||||
## 5) Principio de template
|
## 5) Principio de template
|
||||||
|
|||||||
@@ -11,15 +11,32 @@
|
|||||||
"in_progress",
|
"in_progress",
|
||||||
"blocked",
|
"blocked",
|
||||||
"done"
|
"done"
|
||||||
|
],
|
||||||
|
"valid_types": [
|
||||||
|
"feature",
|
||||||
|
"fix",
|
||||||
|
"bug",
|
||||||
|
"chore"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"template_feature_schema": {
|
"template_feature_schema": {
|
||||||
"id": "F-001",
|
"id": "F-001",
|
||||||
"title": "Título de la feature",
|
"type": "feature",
|
||||||
"description": "Descripción funcional",
|
"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": [
|
"acceptance": [
|
||||||
"Criterio 1",
|
"Flow works end to end",
|
||||||
"Criterio 2"
|
"No break old behavior"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"created_at": "YYYY-MM-DD",
|
"created_at": "YYYY-MM-DD",
|
||||||
|
|||||||
38
docs/repository-layout.md
Normal file
38
docs/repository-layout.md
Normal file
@@ -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.
|
||||||
66
docs/scripts-reference.md
Normal file
66
docs/scripts-reference.md
Normal file
@@ -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/<feature_id>/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.
|
||||||
11
features/README.md
Normal file
11
features/README.md
Normal file
@@ -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/`.
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
[behave]
|
[behave]
|
||||||
paths = features/
|
paths = features/
|
||||||
format = pretty
|
format = pretty
|
||||||
tags = @F-001
|
|
||||||
|
|
||||||
# Para ejecutar solo smoke tests:
|
# Examples:
|
||||||
|
# behave features/
|
||||||
# behave features/ --tags @smoke
|
# behave features/ --tags @smoke
|
||||||
|
# behave features/ --tags ~@slow
|
||||||
# Para excluir tests lentos:
|
|
||||||
# behave features/ --tags ~@slow
|
|
||||||
|
|||||||
0
features/steps/.gitkeep
Normal file
0
features/steps/.gitkeep
Normal file
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -4,18 +4,19 @@ roles:
|
|||||||
leader:
|
leader:
|
||||||
emoji: "🧭"
|
emoji: "🧭"
|
||||||
can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"]
|
can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"]
|
||||||
cannot_edit: ["src/", "tests/"]
|
cannot_edit: ["project/", "tests/"]
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- plan
|
- plan
|
||||||
- orchestrate
|
- orchestrate
|
||||||
- enforce_gates
|
- enforce_gates
|
||||||
|
- publish_ticket_changes
|
||||||
- close_feature
|
- close_feature
|
||||||
- issue_orders_in_english_caveman
|
- issue_orders_in_english_caveman
|
||||||
|
|
||||||
triager:
|
triager:
|
||||||
emoji: "🧩"
|
emoji: "🧩"
|
||||||
can_edit: ["backlog/", "work/artifacts/", "spec/"]
|
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:
|
responsibilities:
|
||||||
- normalize_requests
|
- normalize_requests
|
||||||
- create_tickets_in_english_caveman
|
- create_tickets_in_english_caveman
|
||||||
@@ -24,20 +25,21 @@ roles:
|
|||||||
architect:
|
architect:
|
||||||
emoji: "🏗️"
|
emoji: "🏗️"
|
||||||
can_edit: ["spec/", "harness/contracts/", "docs/"]
|
can_edit: ["spec/", "harness/contracts/", "docs/"]
|
||||||
cannot_edit: ["src/", "tests/", "backlog/features.json:status"]
|
cannot_edit: ["project/", "tests/", "backlog/features.json:status"]
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- design
|
- design
|
||||||
- update_contracts
|
- update_contracts
|
||||||
|
|
||||||
implementer:
|
implementer:
|
||||||
emoji: "🛠️"
|
emoji: "🛠️"
|
||||||
can_edit: ["src/", "tests/", "work/artifacts/"]
|
can_edit: ["project/", "tests/", "work/artifacts/"]
|
||||||
cannot_edit:
|
cannot_edit:
|
||||||
- "backlog/features.json:done"
|
- "backlog/features.json:done"
|
||||||
- "work/history.md"
|
- "work/history.md"
|
||||||
- "work/artifacts/*/reviewer.json"
|
- "work/artifacts/*/reviewer.json"
|
||||||
- "work/artifacts/*/security.json"
|
- "work/artifacts/*/security.json"
|
||||||
- "work/artifacts/*/qa.json"
|
- "work/artifacts/*/qa.json"
|
||||||
|
- "work/artifacts/*/publish.json"
|
||||||
- "work/artifacts/*/leader-close.json"
|
- "work/artifacts/*/leader-close.json"
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- implement_feature
|
- implement_feature
|
||||||
@@ -47,7 +49,7 @@ roles:
|
|||||||
reviewer:
|
reviewer:
|
||||||
emoji: "🔍"
|
emoji: "🔍"
|
||||||
can_edit: ["work/artifacts/"]
|
can_edit: ["work/artifacts/"]
|
||||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- technical_review
|
- technical_review
|
||||||
- emit_reviewer_verdict
|
- emit_reviewer_verdict
|
||||||
@@ -55,7 +57,7 @@ roles:
|
|||||||
security:
|
security:
|
||||||
emoji: "🔒"
|
emoji: "🔒"
|
||||||
can_edit: ["work/artifacts/"]
|
can_edit: ["work/artifacts/"]
|
||||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- sast
|
- sast
|
||||||
- dependency_review
|
- dependency_review
|
||||||
@@ -65,7 +67,7 @@ roles:
|
|||||||
qa:
|
qa:
|
||||||
emoji: "🧪"
|
emoji: "🧪"
|
||||||
can_edit: ["work/artifacts/"]
|
can_edit: ["work/artifacts/"]
|
||||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||||
responsibilities:
|
responsibilities:
|
||||||
- acceptance_traceability
|
- acceptance_traceability
|
||||||
- integration_e2e_checks
|
- integration_e2e_checks
|
||||||
@@ -75,7 +77,7 @@ roles:
|
|||||||
documenter:
|
documenter:
|
||||||
emoji: "📚"
|
emoji: "📚"
|
||||||
can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"]
|
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:
|
responsibilities:
|
||||||
- document_feature_changes
|
- document_feature_changes
|
||||||
- update_user_docs
|
- update_user_docs
|
||||||
|
|||||||
@@ -63,9 +63,16 @@ stages:
|
|||||||
- work/artifacts/<feature_id>/leader-close.json
|
- work/artifacts/<feature_id>/leader-close.json
|
||||||
- work/history.md
|
- work/history.md
|
||||||
|
|
||||||
|
- name: publish
|
||||||
|
owner: leader
|
||||||
|
required: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/publish.json
|
||||||
|
|
||||||
close_requirements:
|
close_requirements:
|
||||||
- reviewer.json.verdict == "APPROVED"
|
- reviewer.json.verdict == "APPROVED"
|
||||||
- security.json.verdict == "APPROVED"
|
- security.json.verdict == "APPROVED"
|
||||||
- qa.json.verdict == "APPROVED"
|
- qa.json.verdict == "APPROVED"
|
||||||
- documenter.md exists
|
- documenter.md exists
|
||||||
|
- publish.json.verdict == "PUBLISHED"
|
||||||
- scripts/verify.sh exit_code == 0
|
- scripts/verify.sh exit_code == 0
|
||||||
|
|||||||
11
project/README.md
Normal file
11
project/README.md
Normal file
@@ -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.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["pytest:."],
|
|
||||||
"testpaths": ["tests"],
|
|
||||||
"pythonpath": ["."],
|
|
||||||
"addopts": "-v"
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,2 @@
|
|||||||
fastapi>=0.100.0
|
# Template core has no hard runtime deps.
|
||||||
uvicorn>=0.23.0
|
# Add project-specific dependencies after running ./scripts/start.sh
|
||||||
pydantic>=2.0.0
|
|
||||||
pytest>=7.0.0
|
|
||||||
httpx>=0.24.0
|
|
||||||
PyJWT>=2.8.0
|
|
||||||
bcrypt>=4.0.0
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
|
STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
|
||||||
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
|
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
|
||||||
|
WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml'
|
||||||
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
|
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
|
||||||
|
VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'}
|
||||||
|
|
||||||
DEFAULT_EMOJIS = {
|
DEFAULT_EMOJIS = {
|
||||||
'leader': '🧭',
|
'leader': '🧭',
|
||||||
@@ -26,6 +28,7 @@ GATE_FILES = {
|
|||||||
'security': 'security.json',
|
'security': 'security.json',
|
||||||
'qa': 'qa.json',
|
'qa': 'qa.json',
|
||||||
'documenter': 'documenter.md',
|
'documenter': 'documenter.md',
|
||||||
|
'publish': 'publish.json',
|
||||||
'leader': 'leader-close.json',
|
'leader': 'leader-close.json',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,28 @@ def load_role_emojis():
|
|||||||
return 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():
|
def default_status():
|
||||||
return {
|
return {
|
||||||
'feature_id': None,
|
'feature_id': None,
|
||||||
@@ -99,7 +124,8 @@ def gate_status(feature_id):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
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:
|
except Exception:
|
||||||
gates[gate] = 'invalid'
|
gates[gate] = 'invalid'
|
||||||
return gates
|
return gates
|
||||||
@@ -115,10 +141,25 @@ def render_gate(gate, state, emojis):
|
|||||||
label = {
|
label = {
|
||||||
'leader': 'close',
|
'leader': 'close',
|
||||||
'documenter': 'docs',
|
'documenter': 'docs',
|
||||||
|
'publish': 'publish',
|
||||||
}.get(gate, gate)
|
}.get(gate, gate)
|
||||||
return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}"
|
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():
|
def show_status():
|
||||||
status = load_status()
|
status = load_status()
|
||||||
emojis = load_role_emojis()
|
emojis = load_role_emojis()
|
||||||
@@ -141,7 +182,7 @@ def show_status():
|
|||||||
print()
|
print()
|
||||||
print('Gates')
|
print('Gates')
|
||||||
if 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)}")
|
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
|
||||||
else:
|
else:
|
||||||
print(' — Sin feature activa —')
|
print(' — Sin feature activa —')
|
||||||
@@ -162,6 +203,7 @@ def show_status():
|
|||||||
|
|
||||||
|
|
||||||
def set_status(args):
|
def set_status(args):
|
||||||
|
validate_runtime_args(args)
|
||||||
status = load_status()
|
status = load_status()
|
||||||
if args.feature_id is not None:
|
if args.feature_id is not None:
|
||||||
status['feature_id'] = args.feature_id or None
|
status['feature_id'] = args.feature_id or None
|
||||||
|
|||||||
66
scripts/install_into_repo.sh
Executable file
66
scripts/install_into_repo.sh
Executable file
@@ -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"
|
||||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
BACKLOG = ROOT / 'backlog' / 'features.json'
|
BACKLOG = ROOT / 'backlog' / 'features.json'
|
||||||
|
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
|
||||||
|
LEVEL_CHOICES = ('low', 'med', 'high')
|
||||||
|
|
||||||
|
|
||||||
def ask(prompt, default=''):
|
def ask(prompt, default=''):
|
||||||
@@ -12,10 +14,23 @@ def ask(prompt, default=''):
|
|||||||
return value if value else 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):
|
def next_id(features):
|
||||||
nums = []
|
nums = []
|
||||||
for f in features:
|
for feature in features:
|
||||||
fid = str(f.get('id', ''))
|
fid = str(feature.get('id', ''))
|
||||||
if fid.startswith('F-') and fid[2:].isdigit():
|
if fid.startswith('F-') and fid[2:].isdigit():
|
||||||
nums.append(int(fid[2:]))
|
nums.append(int(fid[2:]))
|
||||||
return f"F-{(max(nums) + 1) if nums else 1:03d}"
|
return f"F-{(max(nums) + 1) if nums else 1:03d}"
|
||||||
@@ -26,14 +41,14 @@ def main():
|
|||||||
features = data.get('features', [])
|
features = data.get('features', [])
|
||||||
|
|
||||||
print('Create ticket (English caveman style).')
|
print('Create ticket (English caveman style).')
|
||||||
ttype = ask('Type (feature/fix/bug/chore)', 'feature')
|
ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
|
||||||
title = ask('Title (short EN)', f'{ttype.capitalize()} TODO')
|
title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
|
||||||
problem = ask('Problem (short EN)', 'Need change')
|
problem = ask('Problem (short EN)', 'Need change')
|
||||||
goal = ask('Goal (short EN)', 'Make flow better')
|
goal = ask('Goal (short EN)', 'Make flow better')
|
||||||
scope_in = ask('Scope IN (comma list EN)', 'Core flow')
|
scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
|
||||||
scope_out = ask('Scope OUT (comma list EN)', 'No redesign')
|
scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
|
||||||
risk = ask('Risk (low/med/high)', 'low')
|
risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
|
||||||
priority = ask('Priority (low/med/high)', 'med')
|
priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
|
||||||
|
|
||||||
print('Acceptance bullets (EN caveman). Empty line to end.')
|
print('Acceptance bullets (EN caveman). Empty line to end.')
|
||||||
acceptance = []
|
acceptance = []
|
||||||
@@ -47,29 +62,38 @@ def main():
|
|||||||
acceptance = [
|
acceptance = [
|
||||||
'Flow works end to end',
|
'Flow works end to end',
|
||||||
'No break old behavior',
|
'No break old behavior',
|
||||||
'verify.sh is green'
|
'verify.sh is green',
|
||||||
]
|
]
|
||||||
|
|
||||||
fid = next_id(features)
|
fid = next_id(features)
|
||||||
desc = (
|
desc = (
|
||||||
f"Problem: {problem}. "
|
f"Problem: {problem}. "
|
||||||
f"Goal: {goal}. "
|
f"Goal: {goal}. "
|
||||||
f"Scope IN: {scope_in}. "
|
f"Scope IN: {', '.join(scope_in) or 'none'}. "
|
||||||
f"Scope OUT: {scope_out}. "
|
f"Scope OUT: {', '.join(scope_out) or 'none'}. "
|
||||||
f"Type: {ttype}. Priority: {priority}. Risk: {risk}."
|
f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
|
||||||
)
|
)
|
||||||
|
|
||||||
features.append({
|
features.append({
|
||||||
'id': fid,
|
'id': fid,
|
||||||
|
'type': ticket_type,
|
||||||
'title': title,
|
'title': title,
|
||||||
|
'problem': problem,
|
||||||
|
'goal': goal,
|
||||||
|
'scope_in': scope_in,
|
||||||
|
'scope_out': scope_out,
|
||||||
|
'priority': priority,
|
||||||
|
'risk': risk,
|
||||||
'description': desc,
|
'description': desc,
|
||||||
'acceptance': acceptance,
|
'acceptance': acceptance,
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'created_at': str(date.today()),
|
'created_at': str(date.today()),
|
||||||
'gates': {'review': False, 'security': False, 'qa': False}
|
'gates': {'review': False, 'security': False, 'qa': False},
|
||||||
})
|
})
|
||||||
|
|
||||||
data['features'] = features
|
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')
|
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
print(f'Created {fid}: {title}')
|
print(f'Created {fid}: {title}')
|
||||||
|
|
||||||
|
|||||||
133
scripts/publish_ticket.py
Executable file
133
scripts/publish_ticket.py
Executable file
@@ -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} <url>.')
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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
|
|
||||||
@@ -16,12 +16,16 @@ ask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "=== ARNES start wizard ==="
|
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_NAME="$(ask 'Project name' 'my-project')"
|
||||||
PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')"
|
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')"
|
STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')"
|
||||||
if [ "$STACK_CHOICE" = "2" ]; then
|
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')"
|
ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')"
|
||||||
|
|
||||||
mkdir -p "$APP_DIR"
|
mkdir -p "$APP_DIR"
|
||||||
|
[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" <<EOF
|
||||||
|
# Project code
|
||||||
|
|
||||||
|
This directory holds the real project code.
|
||||||
|
Configured by ARNES start wizard.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$BACKEND" = "python/flask" ]; then
|
||||||
|
mkdir -p "$APP_DIR/templates" "$APP_DIR/static/js" "$APP_DIR/static/css" "$APP_DIR/static/images"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$CSSFW" = "skeleton" ]; then
|
if [ "$CSSFW" = "skeleton" ]; then
|
||||||
mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images"
|
mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images"
|
||||||
cp -n defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css" || true
|
[ -f "$APP_DIR/static/css/normalize.css" ] || cp defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css"
|
||||||
cp -n defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css" || true
|
[ -f "$APP_DIR/static/css/skeleton.css" ] || cp defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css"
|
||||||
cp -n defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png" || true
|
[ -f "$APP_DIR/static/images/favicon.png" ] || cp defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > harness/project.config.json <<JSON
|
cat > harness/project.config.json <<JSON
|
||||||
@@ -82,7 +96,7 @@ APP_DIR=$(python3 - <<'PY'
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
cfg=json.loads(Path('harness/project.config.json').read_text())
|
cfg=json.loads(Path('harness/project.config.json').read_text())
|
||||||
print(cfg.get('app_dir','app'))
|
print(cfg.get('app_dir','project'))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
TEST_CMD=$(python3 - <<'PY'
|
TEST_CMD=$(python3 - <<'PY'
|
||||||
@@ -126,25 +140,34 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
b=Path('backlog/features.json')
|
b = Path('backlog/features.json')
|
||||||
data=json.loads(b.read_text(encoding='utf-8'))
|
data = json.loads(b.read_text(encoding='utf-8'))
|
||||||
data['project']='$PROJECT_NAME'
|
data['project'] = '$PROJECT_NAME'
|
||||||
data['description']='$PROJECT_DESC'
|
data['description'] = '$PROJECT_DESC'
|
||||||
features=data.get('features',[])
|
rules = data.setdefault('rules', {})
|
||||||
|
rules.setdefault('valid_types', ['feature', 'fix', 'bug', 'chore'])
|
||||||
|
features = data.get('features', [])
|
||||||
|
|
||||||
if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features:
|
if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features:
|
||||||
features.append({
|
features.append({
|
||||||
'id':'F-001',
|
'id': 'F-001',
|
||||||
'title':'Bootstrap ARNES on project',
|
'type': 'chore',
|
||||||
'description':'Setup ARNES pipeline and run first complete feature cycle.',
|
'title': 'Bootstrap ARNES on project',
|
||||||
'acceptance':['verify.sh is green','runtime status works','first feature closes with gates'],
|
'problem': 'Need base workflow and control',
|
||||||
'status':'pending',
|
'goal': 'Make ARNES ready on this repo',
|
||||||
'created_at':str(date.today()),
|
'scope_in': ['Harness setup', 'Runtime status', 'First verify cycle'],
|
||||||
'gates':{'review':False,'security':False,'qa':False}
|
'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 is green', 'runtime status works', 'first feature closes with gates'],
|
||||||
|
'status': 'pending',
|
||||||
|
'created_at': str(date.today()),
|
||||||
|
'gates': {'review': False, 'security': False, 'qa': False}
|
||||||
})
|
})
|
||||||
|
|
||||||
data['features']=features
|
data['features'] = features
|
||||||
b.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
b.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
PY
|
PY
|
||||||
|
|
||||||
cat > work/current.md <<EOF
|
cat > work/current.md <<EOF
|
||||||
@@ -169,5 +192,6 @@ echo "Done. Project configured."
|
|||||||
echo "- Config: harness/project.config.json"
|
echo "- Config: harness/project.config.json"
|
||||||
echo "- Local checks: scripts/verify.local.sh"
|
echo "- Local checks: scripts/verify.local.sh"
|
||||||
echo "- Ticket tool: python3 scripts/new_ticket.py"
|
echo "- Ticket tool: python3 scripts/new_ticket.py"
|
||||||
|
echo "- Publish tool: python3 scripts/publish_ticket.py --feature-id F-001"
|
||||||
echo "- Verify: ./scripts/verify.sh"
|
echo "- Verify: ./scripts/verify.sh"
|
||||||
echo "- Runtime: python3 scripts/agent_status.py show"
|
echo "- Runtime: python3 scripts/agent_status.py show"
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Test script for the API."""
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import subprocess
|
|
||||||
import requests
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
SERVER_URL = "http://127.0.0.1:8000"
|
|
||||||
|
|
||||||
def start_server():
|
|
||||||
"""Start the uvicorn server."""
|
|
||||||
subprocess.run([
|
|
||||||
"python3", "-m", "uvicorn",
|
|
||||||
"src.main:app",
|
|
||||||
"--host", "127.0.0.1",
|
|
||||||
"--port", "8000"
|
|
||||||
])
|
|
||||||
|
|
||||||
def wait_for_server(timeout=10):
|
|
||||||
"""Wait for server to be ready."""
|
|
||||||
start = time.time()
|
|
||||||
while time.time() - start < timeout:
|
|
||||||
try:
|
|
||||||
response = requests.get(f"{SERVER_URL}/health", timeout=1)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
time.sleep(0.5)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def test_health():
|
|
||||||
"""Test health endpoint."""
|
|
||||||
response = requests.get(f"{SERVER_URL}/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "healthy"
|
|
||||||
print("✅ Health check passed")
|
|
||||||
|
|
||||||
def test_login():
|
|
||||||
"""Test login endpoint."""
|
|
||||||
response = requests.post(
|
|
||||||
f"{SERVER_URL}/api/v1/auth/login",
|
|
||||||
json={"email": "alice@example.com", "password": "SecurePass123!"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["success"] == True
|
|
||||||
assert "access_token" in data["data"]
|
|
||||||
print("✅ Login endpoint passed")
|
|
||||||
return data["data"]["access_token"]
|
|
||||||
|
|
||||||
def test_login_invalid():
|
|
||||||
"""Test login with invalid credentials."""
|
|
||||||
response = requests.post(
|
|
||||||
f"{SERVER_URL}/api/v1/auth/login",
|
|
||||||
json={"email": "alice@example.com", "password": "WrongPassword!"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
print("✅ Invalid login returns 401")
|
|
||||||
|
|
||||||
def test_profile():
|
|
||||||
"""Test profile endpoint."""
|
|
||||||
response = requests.get(f"{SERVER_URL}/api/v1/profile/me")
|
|
||||||
assert response.status_code == 200
|
|
||||||
print("✅ Profile endpoint passed")
|
|
||||||
|
|
||||||
def run_tests():
|
|
||||||
"""Run all tests."""
|
|
||||||
print("🔧 Starting server...")
|
|
||||||
server_thread = Thread(target=start_server, daemon=True)
|
|
||||||
server_thread.start()
|
|
||||||
|
|
||||||
print("⏳ Waiting for server...")
|
|
||||||
if not wait_for_server():
|
|
||||||
print("❌ Server failed to start")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("✅ Server is ready!\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
test_health()
|
|
||||||
test_login()
|
|
||||||
test_login_invalid()
|
|
||||||
test_profile()
|
|
||||||
print("\n🎉 All tests passed!")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ Test failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = run_tests()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
@@ -18,6 +18,11 @@ echo "── 1) Verificando estructura base ────────────
|
|||||||
required=(
|
required=(
|
||||||
"AGENTS.md"
|
"AGENTS.md"
|
||||||
"CHECKPOINTS.md"
|
"CHECKPOINTS.md"
|
||||||
|
"README.md"
|
||||||
|
"HOWTO.md"
|
||||||
|
"TEMPLATE.md"
|
||||||
|
"docs/repository-layout.md"
|
||||||
|
"docs/scripts-reference.md"
|
||||||
"harness/agents.matrix.yml"
|
"harness/agents.matrix.yml"
|
||||||
"harness/workflow.stages.yml"
|
"harness/workflow.stages.yml"
|
||||||
"harness/policies/governance.md"
|
"harness/policies/governance.md"
|
||||||
@@ -31,14 +36,24 @@ required=(
|
|||||||
"spec/product.md"
|
"spec/product.md"
|
||||||
"spec/tech.md"
|
"spec/tech.md"
|
||||||
"spec/acceptance.md"
|
"spec/acceptance.md"
|
||||||
|
"spec/bdd/README.md"
|
||||||
|
"spec/bdd/features/README.md"
|
||||||
|
"spec/sdd/README.md"
|
||||||
|
"spec/sdd/components/README.md"
|
||||||
|
"spec/sdd/decisions/README.md"
|
||||||
|
"features/README.md"
|
||||||
|
"project/README.md"
|
||||||
"backlog/features.json"
|
"backlog/features.json"
|
||||||
"work/current.md"
|
"work/current.md"
|
||||||
"work/history.md"
|
"work/history.md"
|
||||||
"work/runtime-status.json"
|
"work/runtime-status.json"
|
||||||
"scripts/agent_status.py"
|
"scripts/agent_status.py"
|
||||||
"scripts/new_ticket.py"
|
"scripts/new_ticket.py"
|
||||||
|
"scripts/publish_ticket.py"
|
||||||
|
"scripts/install_into_repo.sh"
|
||||||
"scripts/start.sh"
|
"scripts/start.sh"
|
||||||
"platforms/pi/README.md"
|
"platforms/pi/README.md"
|
||||||
|
"platforms/opencode/README.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
for f in "${required[@]}"; do
|
for f in "${required[@]}"; do
|
||||||
@@ -50,6 +65,20 @@ for f in "${required[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── 1.5) Validando git repo ───────────────────────────"
|
||||||
|
if git rev-parse --is-inside-work-tree >/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 ""
|
||||||
echo "── 2) Validando backlog + gates ───────────────────────"
|
echo "── 2) Validando backlog + gates ───────────────────────"
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
@@ -59,6 +88,7 @@ import sys
|
|||||||
|
|
||||||
root = pathlib.Path('.')
|
root = pathlib.Path('.')
|
||||||
path = root / 'backlog' / 'features.json'
|
path = root / 'backlog' / 'features.json'
|
||||||
|
level_choices = {'low', 'med', 'high'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding='utf-8'))
|
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}")
|
print(f"[FAIL] backlog/features.json inválido: {e}")
|
||||||
sys.exit(1)
|
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', [])
|
features = data.get('features', [])
|
||||||
if not isinstance(features, list):
|
if not isinstance(features, list):
|
||||||
print('[FAIL] features debe ser una lista')
|
print('[FAIL] features debe ser una lista')
|
||||||
@@ -85,25 +117,65 @@ if len(in_progress) > 1:
|
|||||||
for f in features:
|
for f in features:
|
||||||
fid = str(f.get('id', '')).strip()
|
fid = str(f.get('id', '')).strip()
|
||||||
status = f.get('status')
|
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}")
|
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
|
||||||
sys.exit(1)
|
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':
|
if status == 'done':
|
||||||
d = root / 'work' / 'artifacts' / fid
|
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()]
|
missing = [name for name in req if not (d / name).is_file()]
|
||||||
if missing:
|
if missing:
|
||||||
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
|
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
'reviewer.json': 'reviewer',
|
'reviewer.json': ('reviewer', 'APPROVED'),
|
||||||
'security.json': 'security',
|
'security.json': ('security', 'APPROVED'),
|
||||||
'qa.json': 'qa',
|
'qa.json': ('qa', 'APPROVED'),
|
||||||
'leader-close.json': 'leader',
|
'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:
|
try:
|
||||||
obj = json.loads((d / filename).read_text(encoding='utf-8'))
|
obj = json.loads((d / filename).read_text(encoding='utf-8'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -113,8 +185,11 @@ for f in features:
|
|||||||
if obj.get('agent') != agent:
|
if obj.get('agent') != agent:
|
||||||
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
|
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if obj.get('verdict') != 'APPROVED':
|
if obj.get('verdict') != verdict:
|
||||||
print(f"[FAIL] {fid}/{filename} no está APPROVED")
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"[OK] backlog válido ({len(features)} features)")
|
print(f"[OK] backlog válido ({len(features)} features)")
|
||||||
|
|||||||
@@ -10,13 +10,16 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Este directorio contiene especificaciones BDD en formato Gherkin.
|
Este directorio contiene las especificaciones BDD fuente en formato Gherkin.
|
||||||
Los archivos `.feature` sirven como especificación ejecutable.
|
|
||||||
|
Separación recomendada:
|
||||||
|
- `spec/bdd/features/` = source-of-truth de escenarios
|
||||||
|
- `features/` = assets ejecutables del runner (steps, config)
|
||||||
|
|
||||||
### naming conventions
|
### naming conventions
|
||||||
|
|
||||||
```
|
```text
|
||||||
features/
|
spec/bdd/features/
|
||||||
├── <domain>/
|
├── <domain>/
|
||||||
│ ├── <feature-name>.feature
|
│ ├── <feature-name>.feature
|
||||||
│ └── <feature-name>.feature
|
│ └── <feature-name>.feature
|
||||||
|
|||||||
0
spec/bdd/features/.gitkeep
Normal file
0
spec/bdd/features/.gitkeep
Normal file
@@ -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`
|
||||||
|
|
||||||
```
|
Use tags like:
|
||||||
features/
|
- `@F-001`
|
||||||
├── auth/
|
- `@smoke`
|
||||||
│ ├── login.feature
|
- `@regression`
|
||||||
│ └── 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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.
|
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
|
│ ├── architecture.md
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ └── decisions/
|
│ └── decisions/
|
||||||
└── bdd/ # Behavior Driven Development
|
└── bdd/ # Behavior Driven Development source-of-truth
|
||||||
├── README.md
|
├── README.md
|
||||||
├── features/
|
└── features/
|
||||||
└── step_definitions/
|
|
||||||
|
features/ # optional executable BDD runner assets
|
||||||
|
├── behave.ini
|
||||||
|
└── steps/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -152,6 +155,11 @@ spec/bdd/features/
|
|||||||
│ └── purchase.feature
|
│ └── purchase.feature
|
||||||
└── common/
|
└── common/
|
||||||
└── error-handling.feature
|
└── error-handling.feature
|
||||||
|
|
||||||
|
features/
|
||||||
|
├── behave.ini
|
||||||
|
└── steps/
|
||||||
|
└── login_steps.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tags para trazabilidad
|
### Tags para trazabilidad
|
||||||
@@ -224,8 +232,10 @@ Tags disponibles:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Estructura
|
# Estructura
|
||||||
|
spec/bdd/features/
|
||||||
|
└── login.feature
|
||||||
|
|
||||||
features/
|
features/
|
||||||
├── login.feature
|
|
||||||
└── steps/
|
└── steps/
|
||||||
└── login_steps.py
|
└── login_steps.py
|
||||||
|
|
||||||
@@ -237,8 +247,10 @@ behave features/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Estructura
|
# Estructura
|
||||||
|
spec/bdd/features/
|
||||||
|
└── login.feature
|
||||||
|
|
||||||
features/
|
features/
|
||||||
├── login.feature
|
|
||||||
└── step_definitions/
|
└── step_definitions/
|
||||||
└── login_steps.js
|
└── login_steps.js
|
||||||
|
|
||||||
|
|||||||
0
spec/sdd/components/.gitkeep
Normal file
0
spec/sdd/components/.gitkeep
Normal file
@@ -1,74 +0,0 @@
|
|||||||
# Component: <Nombre>
|
|
||||||
|
|
||||||
## 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: ...
|
|
||||||
8
spec/sdd/components/README.md
Normal file
8
spec/sdd/components/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# SDD components
|
||||||
|
|
||||||
|
Put one markdown file per technical component.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `api-gateway.md`
|
||||||
|
- `order-service.md`
|
||||||
|
- `cart-repository.md`
|
||||||
@@ -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
|
|
||||||
@@ -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 <token>
|
|
||||||
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`
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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 <token>
|
|
||||||
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 <token>
|
|
||||||
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`
|
|
||||||
0
spec/sdd/decisions/.gitkeep
Normal file
0
spec/sdd/decisions/.gitkeep
Normal file
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
7
spec/sdd/decisions/README.md
Normal file
7
spec/sdd/decisions/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# SDD decisions
|
||||||
|
|
||||||
|
Put ADRs (Architecture Decision Records) here.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `001-use-flask.md`
|
||||||
|
- `002-use-mariadb.md`
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Package init."""
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
"""API init."""
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
220
src/api/auth.py
220
src/api/auth.py
@@ -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)
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
116
src/main.py
116
src/main.py
@@ -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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Models init."""
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Services init."""
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Cambiar Contraseña - ARNES</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
||||||
padding: 40px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrapper {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
padding-right: 45px;
|
|
||||||
border: 2px solid #e1e1e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin: 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement.valid { color: #2e7d32; }
|
|
||||||
.requirement.invalid { color: #c62828; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.loading {
|
|
||||||
position: relative;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.loading::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
margin: -10px 0 0 -10px;
|
|
||||||
border: 2px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: transparent;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.show { display: block; }
|
|
||||||
.message.error { background: #fee; color: #c00; border: 1px solid #fcc; }
|
|
||||||
.message.success { background: #efe; color: #060; border: 1px solid #cfc; }
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.check-icon { font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🔑 Cambiar Contraseña</h1>
|
|
||||||
<p class="subtitle">Actualiza tu contraseña de seguridad</p>
|
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
|
||||||
|
|
||||||
<form id="changePasswordForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="currentPassword">Contraseña Actual</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input type="password" id="currentPassword" name="currentPassword"
|
|
||||||
placeholder="••••••••" required autocomplete="current-password">
|
|
||||||
<button type="button" class="toggle-btn" onclick="togglePassword('currentPassword')">👁️</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="newPassword">Nueva Contraseña</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input type="password" id="newPassword" name="newPassword"
|
|
||||||
placeholder="••••••••" required autocomplete="new-password">
|
|
||||||
<button type="button" class="toggle-btn" onclick="togglePassword('newPassword')">👁️</button>
|
|
||||||
</div>
|
|
||||||
<div class="requirements" id="requirements">
|
|
||||||
<div class="requirement" id="req-length">○ Mínimo 8 caracteres</div>
|
|
||||||
<div class="requirement" id="req-upper">○ Al menos 1 mayúscula</div>
|
|
||||||
<div class="requirement" id="req-lower">○ Al menos 1 minúscula</div>
|
|
||||||
<div class="requirement" id="req-number">○ Al menos 1 número</div>
|
|
||||||
<div class="requirement" id="req-special">○ Al menos 1 carácter especial</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="confirmPassword">Confirmar Nueva Contraseña</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input type="password" id="confirmPassword" name="confirmPassword"
|
|
||||||
placeholder="••••••••" required autocomplete="new-password">
|
|
||||||
<button type="button" class="toggle-btn" onclick="togglePassword('confirmPassword')">👁️</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="submitBtn" class="btn" disabled>Cambiar Contraseña</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<a href="/ui/dashboard.html" class="back-link" id="backLink" style="display: none;">← Volver al Dashboard</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = window.location.origin;
|
|
||||||
const form = document.getElementById('changePasswordForm');
|
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
|
||||||
const message = document.getElementById('message');
|
|
||||||
|
|
||||||
const reqs = {
|
|
||||||
length: /.{8,}/,
|
|
||||||
upper: /[A-Z]/,
|
|
||||||
lower: /[a-z]/,
|
|
||||||
number: /[0-9]/,
|
|
||||||
special: /[!@#$%^&*()_+\-=\[\]{}|;':",.\/<>?]/
|
|
||||||
};
|
|
||||||
|
|
||||||
function togglePassword(id) {
|
|
||||||
const input = document.getElementById(id);
|
|
||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRequirements(password) {
|
|
||||||
let valid = true;
|
|
||||||
|
|
||||||
updateReq('length', reqs.length.test(password));
|
|
||||||
updateReq('upper', reqs.upper.test(password));
|
|
||||||
updateReq('lower', reqs.lower.test(password));
|
|
||||||
updateReq('number', reqs.number.test(password));
|
|
||||||
updateReq('special', reqs.special.test(password));
|
|
||||||
|
|
||||||
for (const key in reqs) {
|
|
||||||
if (!reqs[key].test(password)) valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirm = document.getElementById('confirmPassword').value;
|
|
||||||
if (confirm && confirm !== password) valid = false;
|
|
||||||
|
|
||||||
submitBtn.disabled = !valid;
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateReq(id, isValid) {
|
|
||||||
const el = document.getElementById('req-' + id);
|
|
||||||
el.className = 'requirement ' + (isValid ? 'valid' : 'invalid');
|
|
||||||
el.textContent = (isValid ? '✓' : '○') + ' ' + el.textContent.substring(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real-time validation
|
|
||||||
document.getElementById('newPassword').addEventListener('input', (e) => {
|
|
||||||
checkRequirements(e.target.value);
|
|
||||||
message.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('confirmPassword').addEventListener('input', () => {
|
|
||||||
const pass = document.getElementById('newPassword').value;
|
|
||||||
const confirm = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (confirm && pass !== confirm) {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
} else if (pass) {
|
|
||||||
checkRequirements(pass);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check auth on load
|
|
||||||
function checkAuth() {
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
if (!token) {
|
|
||||||
showMessage('No hay sesión activa. Redirigiendo al login...', 'error');
|
|
||||||
setTimeout(() => window.location.href = '/ui/login.html', 2000);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
document.getElementById('backLink').style.display = 'block';
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
|
|
||||||
// Form submit
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const current = document.getElementById('currentPassword').value;
|
|
||||||
const newPass = document.getElementById('newPassword').value;
|
|
||||||
const confirm = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (newPass !== confirm) {
|
|
||||||
showMessage('❌ Las contraseñas nuevas no coinciden', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkRequirements(newPass)) {
|
|
||||||
showMessage('❌ La nueva contraseña no cumple los requisitos', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
|
|
||||||
submitBtn.classList.add('loading');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
message.classList.remove('show');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/users/user-001/change-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
current_password: current,
|
|
||||||
new_password: newPass,
|
|
||||||
confirm_password: confirm
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage('✅ Contraseña cambiada exitosamente!', 'success');
|
|
||||||
form.reset();
|
|
||||||
Object.keys(reqs).forEach(key => updateReq(key, false));
|
|
||||||
} else {
|
|
||||||
showMessage('❌ ' + (data.detail?.message || data.message || 'Error'), 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('❌ Error de conexión: ' + error.message, 'error');
|
|
||||||
} finally {
|
|
||||||
submitBtn.classList.remove('loading');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function showMessage(msg, type) {
|
|
||||||
message.textContent = msg;
|
|
||||||
message.className = 'message show ' + type;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dashboard - ARNES</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #f5f6fa;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 20px 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 { font-size: 24px; }
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn:hover { background: rgba(255,255,255,0.3); }
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 40px auto;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-info {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
word-break: break-all;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.valid { background: #e8f5e9; color: #2e7d32; }
|
|
||||||
.status.invalid { background: #ffebee; color: #c62828; }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); }
|
|
||||||
|
|
||||||
.error-box {
|
|
||||||
background: #fee;
|
|
||||||
color: #c00;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #fcc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading { color: #666; font-style: italic; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>📊 ARNES Dashboard</h1>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="user-avatar">👤</div>
|
|
||||||
<span id="userEmail">Cargando...</span>
|
|
||||||
</div>
|
|
||||||
<button class="logout-btn" onclick="logout()">Cerrar Sesión</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card">
|
|
||||||
<h2>🔐 Estado de Autenticación</h2>
|
|
||||||
<div class="card-content">
|
|
||||||
<p>Token activo: <span id="tokenStatus" class="loading">Verificando...</span></p>
|
|
||||||
<div id="tokenDetails" class="token-info"></div>
|
|
||||||
<button class="btn-primary" onclick="refreshToken()">🔄 Refrescar Token</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>🔑 Operaciones</h2>
|
|
||||||
<div class="card-content">
|
|
||||||
<p>Acciones disponibles para el usuario autenticado:</p>
|
|
||||||
<ul style="margin-top: 10px; padding-left: 20px; line-height: 2;">
|
|
||||||
<li><a href="/ui/change-password.html">Cambiar contraseña</a></li>
|
|
||||||
<li><a href="#" onclick="testValidate()">Validar token</a></li>
|
|
||||||
<li><a href="#" onclick="testLogout()">Cerrar sesión</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>🧪 Debug Info</h2>
|
|
||||||
<div class="card-content">
|
|
||||||
<p><strong>API Base:</strong> <span id="apiBase"></span></p>
|
|
||||||
<p><strong>Token expiry:</strong> <span id="tokenExpiry"></span></p>
|
|
||||||
<div class="token-info" style="margin-top: 15px;">
|
|
||||||
<strong>Access Token:</strong><br>
|
|
||||||
<span id="accessToken">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="message" style="margin-top: 20px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = window.location.origin;
|
|
||||||
|
|
||||||
function getToken() {
|
|
||||||
return localStorage.getItem('access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRefreshToken() {
|
|
||||||
return localStorage.getItem('refresh_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAuth() {
|
|
||||||
const token = getToken();
|
|
||||||
const email = localStorage.getItem('user_email');
|
|
||||||
|
|
||||||
document.getElementById('userEmail').textContent = email || 'Usuario';
|
|
||||||
document.getElementById('apiBase').textContent = API_BASE;
|
|
||||||
document.getElementById('accessToken').textContent = token ? token.substring(0, 50) + '...' : 'No hay token';
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
document.getElementById('tokenStatus').innerHTML = '<span class="status invalid">Sin token</span>';
|
|
||||||
showMessage('❌ No hay sesión activa. Redirigiendo...', 'error');
|
|
||||||
setTimeout(() => window.location.href = '/ui/login.html', 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/validate`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.valid) {
|
|
||||||
document.getElementById('tokenStatus').innerHTML = '<span class="status valid">✓ Token válido</span>';
|
|
||||||
document.getElementById('tokenDetails').textContent =
|
|
||||||
`Usuario: ${data.payload.email}\nID: ${data.payload.sub}\nRol: ${data.payload.role}`;
|
|
||||||
document.getElementById('tokenExpiry').textContent =
|
|
||||||
new Date(data.payload.exp * 1000).toLocaleString();
|
|
||||||
} else {
|
|
||||||
document.getElementById('tokenStatus').innerHTML = '<span class="status invalid">✗ ' + data.error + '</span>';
|
|
||||||
document.getElementById('tokenDetails').textContent = data.error || 'Token inválido o expirado';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
document.getElementById('tokenStatus').innerHTML = '<span class="status invalid">Error de conexión</span>';
|
|
||||||
showMessage('❌ No se pudo verificar el token: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshToken() {
|
|
||||||
const refresh = getRefreshToken();
|
|
||||||
if (!refresh) {
|
|
||||||
showMessage('❌ No hay refresh token disponible', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: refresh })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
localStorage.setItem('access_token', data.data.access_token);
|
|
||||||
localStorage.setItem('refresh_token', data.data.refresh_token);
|
|
||||||
showMessage('✅ Token refrescado exitosamente!', 'success');
|
|
||||||
checkAuth();
|
|
||||||
} else {
|
|
||||||
showMessage('❌ Error al refrescar: ' + (data.detail?.message || data.message), 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('❌ Error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testValidate() {
|
|
||||||
const token = getToken();
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/validate`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
showMessage(data.valid ? '✅ Token válido' : '❌ ' + data.error, data.valid ? 'success' : 'error');
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('❌ Error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testLogout() {
|
|
||||||
const token = getToken();
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ revoke_all: false })
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
showMessage('✅ Sesión cerrada', 'success');
|
|
||||||
setTimeout(() => window.location.href = '/ui/login.html', 1500);
|
|
||||||
} else {
|
|
||||||
showMessage('❌ ' + (data.detail?.message || 'Error'), 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('❌ Error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_email');
|
|
||||||
window.location.href = '/ui/login.html';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(msg, type) {
|
|
||||||
const div = document.getElementById('message');
|
|
||||||
div.className = type === 'error' ? 'error-box' : '';
|
|
||||||
div.textContent = msg;
|
|
||||||
div.style.padding = '15px';
|
|
||||||
div.style.borderRadius = '8px';
|
|
||||||
div.style.marginTop = '20px';
|
|
||||||
div.style.background = type === 'error' ? '#fee' : '#efe';
|
|
||||||
div.style.color = type === 'error' ? '#c00' : '#060';
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Iniciar Sesión - ARNES</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.login-container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
||||||
padding: 40px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
.login-header { text-align: center; margin-bottom: 32px; }
|
|
||||||
.login-header h1 { color: #333; font-size: 24px; margin-bottom: 8px; }
|
|
||||||
.login-header p { color: #666; font-size: 14px; }
|
|
||||||
.form-group { margin-bottom: 20px; }
|
|
||||||
.form-group label { display: block; margin-bottom: 8px; color: #333; font-weight: 500; font-size: 14px; }
|
|
||||||
.input-wrapper { position: relative; }
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 2px solid #e1e1e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.form-group input:focus { border-color: #667eea; box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1); }
|
|
||||||
.form-group input.error { border-color: #e74c3c; animation: shake 0.5s ease-in-out; }
|
|
||||||
@keyframes shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-5px); }
|
|
||||||
75% { transform: translateX(5px); }
|
|
||||||
}
|
|
||||||
.error-text { color: #e74c3c; font-size: 12px; margin-top: 6px; display: none; }
|
|
||||||
.error-text.show { display: block; }
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); }
|
|
||||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
|
||||||
.btn.loading { position: relative; color: transparent; }
|
|
||||||
.btn.loading::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 20px; height: 20px;
|
|
||||||
top: 50%; left: 50%;
|
|
||||||
margin: -10px 0 0 -10px;
|
|
||||||
border: 2px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: transparent;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; display: none; }
|
|
||||||
.message.show { display: block; }
|
|
||||||
.message.error { background: #fee; color: #c00; border: 1px solid #fcc; }
|
|
||||||
.message.success { background: #efe; color: #060; border: 1px solid #cfc; }
|
|
||||||
.toast {
|
|
||||||
position: fixed; top: 20px; right: 20px;
|
|
||||||
background: #27ae60; color: white;
|
|
||||||
padding: 16px 24px; border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
transform: translateX(120%);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.toast.show { transform: translateX(0); }
|
|
||||||
.toast.hide { transform: translateX(120%); }
|
|
||||||
.debug-info {
|
|
||||||
margin-top: 20px; padding: 12px;
|
|
||||||
background: #f5f5f5; border-radius: 8px;
|
|
||||||
font-size: 12px; color: #666;
|
|
||||||
}
|
|
||||||
.debug-info code { background: #eee; padding: 2px 4px; border-radius: 3px; }
|
|
||||||
|
|
||||||
/* Test button styling */
|
|
||||||
.test-btn {
|
|
||||||
background: #333; color: white;
|
|
||||||
border: none; padding: 10px 15px;
|
|
||||||
border-radius: 6px; cursor: pointer;
|
|
||||||
font-size: 12px; margin: 5px;
|
|
||||||
}
|
|
||||||
.test-btn:hover { background: #555; }
|
|
||||||
.test-output {
|
|
||||||
margin-top: 10px; padding: 10px;
|
|
||||||
background: #222; color: #0f0;
|
|
||||||
font-family: monospace; font-size: 11px;
|
|
||||||
border-radius: 4px; max-height: 200px; overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-container">
|
|
||||||
<div class="login-header">
|
|
||||||
<h1>🔐 ARNES</h1>
|
|
||||||
<p>Iniciar sesión en tu cuenta</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="errorMessage" class="message error"></div>
|
|
||||||
<div id="successMessage" class="message success"></div>
|
|
||||||
|
|
||||||
<form id="loginForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Correo electrónico</label>
|
|
||||||
<input type="email" id="email" name="email" placeholder="tu@email.com" required autocomplete="email">
|
|
||||||
<div id="emailError" class="error-text">Ingresa un email válido</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Contraseña</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<input type="password" id="password" name="password" placeholder="••••••••" required autocomplete="current-password">
|
|
||||||
<button type="button" id="togglePassword" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; font-size: 18px;">👁️</button>
|
|
||||||
</div>
|
|
||||||
<div id="passwordError" class="error-text">La contraseña es requerida</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="loginBtn" class="btn">Iniciar Sesión</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="debug-info">
|
|
||||||
<strong>Usuarios de prueba:</strong><br>
|
|
||||||
<code>alice@example.com</code> / <code>SecurePass123!</code><br><br>
|
|
||||||
<button class="test-btn" onclick="testDirect()">🧪 Test Directo</button>
|
|
||||||
<button class="test-btn" onclick="clearStorage()">🗑️ Limpiar Storage</button>
|
|
||||||
<button class="test-btn" onclick="checkStorage()">📋 Ver Storage</button>
|
|
||||||
<div id="testOutput" class="test-output" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="welcomeToast" class="toast">✅ ¡Bienvenido!</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Configuration
|
|
||||||
const API_BASE = window.location.origin;
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const loginForm = document.getElementById('loginForm');
|
|
||||||
const emailInput = document.getElementById('email');
|
|
||||||
const passwordInput = document.getElementById('password');
|
|
||||||
const loginBtn = document.getElementById('loginBtn');
|
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
|
||||||
const successMessage = document.getElementById('successMessage');
|
|
||||||
const emailError = document.getElementById('emailError');
|
|
||||||
const passwordError = document.getElementById('passwordError');
|
|
||||||
const togglePasswordBtn = document.getElementById('togglePassword');
|
|
||||||
const welcomeToast = document.getElementById('welcomeToast');
|
|
||||||
const testOutput = document.getElementById('testOutput');
|
|
||||||
|
|
||||||
// Debug helper
|
|
||||||
function log(msg) {
|
|
||||||
console.log('[ARNES]', msg);
|
|
||||||
testOutput.style.display = 'block';
|
|
||||||
testOutput.innerHTML += new Date().toLocaleTimeString() + ' ' + msg + '<br>';
|
|
||||||
testOutput.scrollTop = testOutput.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test functions
|
|
||||||
function testDirect() {
|
|
||||||
log('Iniciando test directo...');
|
|
||||||
log('API_BASE: ' + API_BASE);
|
|
||||||
log('URL: ' + API_BASE + '/api/v1/auth/login');
|
|
||||||
|
|
||||||
fetch(API_BASE + '/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: 'alice@example.com',
|
|
||||||
password: 'SecurePass123!'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(r => {
|
|
||||||
log('Status: ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
log('Response: ' + JSON.stringify(data, null, 2));
|
|
||||||
if (data.success) {
|
|
||||||
log('✅ Login exitoso!');
|
|
||||||
log('Token: ' + data.data.access_token.substring(0, 30) + '...');
|
|
||||||
localStorage.setItem('access_token', data.data.access_token);
|
|
||||||
localStorage.setItem('refresh_token', data.data.refresh_token);
|
|
||||||
} else {
|
|
||||||
log('❌ Error: ' + (data.message || data.detail?.message));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
log('❌ Error de red: ' + err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStorage() {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_email');
|
|
||||||
log('Storage limpiado');
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkStorage() {
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const refresh = localStorage.getItem('refresh_token');
|
|
||||||
const email = localStorage.getItem('user_email');
|
|
||||||
log('access_token: ' + (token ? token.substring(0, 30) + '...' : 'null'));
|
|
||||||
log('refresh_token: ' + (refresh ? 'existe' : 'null'));
|
|
||||||
log('user_email: ' + (email || 'null'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle password
|
|
||||||
togglePasswordBtn.addEventListener('click', () => {
|
|
||||||
passwordInput.type = passwordInput.type === 'password' ? 'text' : 'password';
|
|
||||||
togglePasswordBtn.textContent = passwordInput.type === 'password' ? '👁️' : '🙈';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear errors on input
|
|
||||||
emailInput.addEventListener('input', () => emailError.classList.remove('show'));
|
|
||||||
passwordInput.addEventListener('input', () => {
|
|
||||||
passwordError.classList.remove('show');
|
|
||||||
errorMessage.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
function validateForm() {
|
|
||||||
let valid = true;
|
|
||||||
if (!emailInput.value || !emailInput.value.includes('@')) {
|
|
||||||
emailError.classList.add('show');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
if (!passwordInput.value) {
|
|
||||||
passwordError.classList.add('show');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login form submit
|
|
||||||
loginForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
errorMessage.classList.remove('show');
|
|
||||||
successMessage.classList.remove('show');
|
|
||||||
loginBtn.classList.add('loading');
|
|
||||||
loginBtn.disabled = true;
|
|
||||||
|
|
||||||
log('Enviando login...');
|
|
||||||
log('Email: ' + emailInput.value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_BASE + '/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: emailInput.value,
|
|
||||||
password: passwordInput.value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
log('Response status: ' + response.status);
|
|
||||||
const data = await response.json();
|
|
||||||
log('Response: ' + JSON.stringify(data));
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
localStorage.setItem('access_token', data.data.access_token);
|
|
||||||
localStorage.setItem('refresh_token', data.data.refresh_token);
|
|
||||||
localStorage.setItem('user_email', emailInput.value);
|
|
||||||
|
|
||||||
successMessage.textContent = '✅ ' + data.message;
|
|
||||||
successMessage.classList.add('show');
|
|
||||||
welcomeToast.classList.add('show');
|
|
||||||
|
|
||||||
setTimeout(() => welcomeToast.classList.remove('show'), 3000);
|
|
||||||
setTimeout(() => { window.location.href = '/ui/dashboard.html'; }, 2000);
|
|
||||||
} else {
|
|
||||||
const msg = data.detail?.message || data.message || 'Error desconocido';
|
|
||||||
errorMessage.textContent = '⚠️ ' + msg;
|
|
||||||
errorMessage.classList.add('show');
|
|
||||||
passwordInput.value = '';
|
|
||||||
passwordInput.classList.add('error');
|
|
||||||
setTimeout(() => passwordInput.classList.remove('error'), 500);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log('Error: ' + error.message);
|
|
||||||
errorMessage.textContent = '⚠️ Error de conexión: ' + error.message;
|
|
||||||
errorMessage.classList.add('show');
|
|
||||||
} finally {
|
|
||||||
loginBtn.classList.remove('loading');
|
|
||||||
loginBtn.textContent = 'Iniciar Sesión';
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check existing session on load
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
log('Página cargada');
|
|
||||||
checkStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-run check on load
|
|
||||||
setTimeout(checkStorage, 500);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -4,15 +4,15 @@ Este pack sirve para arrancar ARNES en 2 escenarios:
|
|||||||
|
|
||||||
## A) Proyecto nuevo (greenfield)
|
## A) Proyecto nuevo (greenfield)
|
||||||
1. Crea repo vacío.
|
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`).
|
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:
|
5. Ejecuta:
|
||||||
- `./scripts/verify.sh`
|
- `./scripts/verify.sh`
|
||||||
- `python3 scripts/agent_status.py show`
|
- `python3 scripts/agent_status.py show`
|
||||||
|
|
||||||
## B) Proyecto ya empezado (brownfield)
|
## 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.
|
2. Mantén tu código actual intacto.
|
||||||
3. Añade checks del dominio en `scripts/verify.local.sh`.
|
3. Añade checks del dominio en `scripts/verify.local.sh`.
|
||||||
4. Define features reales del proyecto en `backlog/features.json`.
|
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
|
## Reglas mínimas
|
||||||
- 1 sola feature en `in_progress`.
|
- 1 sola feature en `in_progress`.
|
||||||
|
- Tipos válidos: `feature`, `fix`, `bug`, `chore`.
|
||||||
- `done` requiere gates: `review/security/qa`.
|
- `done` requiere gates: `review/security/qa`.
|
||||||
|
- `done` requiere commit+push final del ticket.
|
||||||
- Evidencia en `work/artifacts/<feature_id>/`.
|
- Evidencia en `work/artifacts/<feature_id>/`.
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
{
|
{
|
||||||
"id": "F-001",
|
"id": "F-001",
|
||||||
"title": "Bootstrap de proyecto con ARNES",
|
"type": "chore",
|
||||||
"description": "Configurar pipeline SDD en este repositorio y validar primer ciclo completo.",
|
"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": [
|
"acceptance": [
|
||||||
"verify.sh en verde",
|
"verify.sh is green",
|
||||||
"runtime-status operativo",
|
"runtime status works",
|
||||||
"primera feature cerrada con gates"
|
"first feature closes with gates"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"created_at": "YYYY-MM-DD",
|
"created_at": "YYYY-MM-DD",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Unit tests init."""
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
"""Unit tests init."""
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user