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
|
||||
- Stack: FastAPI + PostgreSQL
|
||||
- Deploy: Kubernetes
|
||||
- Regla extra: toda migración requiere evidencia en `work/artifacts/<id>/db.md`
|
||||
- Regla extra: `scripts/verify.local.sh` debe ejecutar `alembic check` y `pytest -m smoke`
|
||||
## Example
|
||||
- App dir: `project/`
|
||||
- Deploy target: staging Kubernetes cluster
|
||||
- Extra rule: DB changes require `work/artifacts/<id>/db.md`
|
||||
- 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.
|
||||
|
||||
## Arranque obligatorio
|
||||
1. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`.
|
||||
2. Leer `work/current.md`.
|
||||
3. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
|
||||
4. Ejecutar `./scripts/verify.sh`.
|
||||
5. Mostrar estado runtime: `python3 scripts/agent_status.py show`.
|
||||
6. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
|
||||
1. Usar ARNES dentro de un repo de proyecto real, no dentro del repo fuente de ARNES.
|
||||
2. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`.
|
||||
3. Leer `work/current.md`.
|
||||
4. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
|
||||
5. Ejecutar `./scripts/verify.sh`.
|
||||
6. Mostrar estado runtime: `python3 scripts/agent_status.py show`.
|
||||
7. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
|
||||
|
||||
## Ticket creation policy
|
||||
- Tickets are created by `leader` (or `triager`) only.
|
||||
@@ -30,6 +31,7 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en
|
||||
- `implementer` nunca marca `done`.
|
||||
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
|
||||
- `done` requiere evidencia de `documenter`: `work/artifacts/<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.
|
||||
|
||||
## Modelo por tarea (token-aware)
|
||||
@@ -37,6 +39,11 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en
|
||||
- Routing config: `harness/models.profiles.yml`
|
||||
- Rules: `harness/policies/model-routing.md`
|
||||
|
||||
## Git publish por ticket
|
||||
- Al terminar una feature/ticket, `leader` debe ejecutar:
|
||||
- `python3 scripts/publish_ticket.py --feature-id F-123`
|
||||
- Esto crea commit + push del ticket y deja evidencia en `work/artifacts/<feature_id>/publish.json`.
|
||||
|
||||
## Extensión por proyecto (overlay)
|
||||
- Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual.
|
||||
- Opcional: `scripts/verify.local.sh` para checks de dominio.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# CHECKPOINTS
|
||||
|
||||
## C1 — Estructura
|
||||
- [ ] Existe `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`.
|
||||
- [ ] Existe `project/`, `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`.
|
||||
- [ ] `project/README.md` existe como placeholder mínimo.
|
||||
|
||||
## C2 — Estado
|
||||
- [ ] Máximo una feature en `in_progress`.
|
||||
- [ ] Estados válidos en backlog.
|
||||
- [ ] Tipos de ticket válidos en backlog.
|
||||
- [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`.
|
||||
|
||||
## C3 — Gates
|
||||
@@ -14,6 +16,7 @@
|
||||
- [ ] Toda feature `done` tiene `qa.json` aprobado.
|
||||
- [ ] Toda feature `done` tiene `leader-close.json` válido.
|
||||
- [ ] Toda feature `done` tiene `documenter.md`.
|
||||
- [ ] Toda feature `done` tiene `publish.json` con commit+push del ticket.
|
||||
|
||||
## C4 — Verificación
|
||||
- [ ] `./scripts/verify.sh` termina en OK.
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
1. Analizar la feature del backlog
|
||||
↓
|
||||
2. Crear SPEC/BBD (architect)
|
||||
↓
|
||||
3. Crear/actualizar SDD (architect)
|
||||
↓
|
||||
4. Generar código + tests (implementer)
|
||||
↓
|
||||
5. Review, Security, QA gates
|
||||
↓
|
||||
6. Cerrar feature
|
||||
```
|
||||
## BDD notes
|
||||
- Put `.feature` files in `spec/bdd/features/`
|
||||
- Put steps in `features/steps/`
|
||||
- Use tags like `@F-001`, `@smoke`, `@regression`
|
||||
|
||||
---
|
||||
|
||||
## Paso 1: Analizar del Backlog
|
||||
|
||||
Ejemplo: F-002 "Gestión de Perfil de Usuario"
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "F-002",
|
||||
"title": "Gestión de Perfil de Usuario",
|
||||
"description": "El usuario puede ver y editar su perfil (nombre, avatar, preferencias).",
|
||||
"acceptance": [
|
||||
"Usuario puede ver su perfil",
|
||||
"Usuario puede editar nombre y avatar",
|
||||
"Usuario puede cambiar preferencias de idioma",
|
||||
"Validación de datos en todos los campos"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 2: Crear SDD (System Design Document)
|
||||
|
||||
### 2.1 Crear componente
|
||||
|
||||
Archivo: `spec/sdd/components/user-profile-service.md`
|
||||
|
||||
```markdown
|
||||
# Component: UserProfileService
|
||||
|
||||
## Responsabilidad
|
||||
Gestionar el perfil de usuario: consulta, actualización de datos básicos y preferencias.
|
||||
|
||||
## Tipo
|
||||
- [x] Microservicio
|
||||
|
||||
## Interfaces
|
||||
|
||||
### API REST
|
||||
|
||||
```
|
||||
GET /api/v1/users/{user_id}/profile
|
||||
Output: { "id", "name", "avatar_url", "language", "created_at" }
|
||||
|
||||
PUT /api/v1/users/{user_id}/profile
|
||||
Input: { "name": string, "avatar_url": string, "language": string }
|
||||
Output: { "id", "name", "avatar_url", "language", "updated_at" }
|
||||
```
|
||||
|
||||
## Validaciones
|
||||
- name: 2-50 caracteres, sin caracteres especiales
|
||||
- avatar_url: URL válida (http/https)
|
||||
- language: enum ['en', 'es', 'fr', 'de']
|
||||
```
|
||||
|
||||
### 2.2 Crear ADR (si hay decisión técnica)
|
||||
|
||||
Archivo: `spec/sdd/decisions/002-almacenamiento-avatar.md`
|
||||
|
||||
---
|
||||
|
||||
## Paso 3: Crear BDD (Behavior Driven Development)
|
||||
|
||||
### 3.1 Crear archivo .feature
|
||||
|
||||
Archivo: `spec/bdd/features/profile/user-profile.feature`
|
||||
|
||||
```gherkin
|
||||
@F-002 @profile
|
||||
Feature: Gestión de Perfil de Usuario
|
||||
|
||||
Como usuario autenticado
|
||||
Quiero gestionar mi perfil
|
||||
Para mantener mis datos actualizados
|
||||
|
||||
@smoke
|
||||
Scenario: Ver perfil de usuario
|
||||
Given un usuario autenticado con ID "user-123"
|
||||
When el usuario solicita ver su perfil
|
||||
Then el sistema retorna datos del perfil
|
||||
And incluye nombre, avatar y preferencias
|
||||
|
||||
Scenario: Editar nombre del perfil
|
||||
Given un usuario autenticado con ID "user-123"
|
||||
And el perfil tiene nombre "Juan"
|
||||
When el usuario actualiza su nombre a "Pedro"
|
||||
Then el perfil muestra nombre "Pedro"
|
||||
And la fecha de actualización se registra
|
||||
|
||||
@negative
|
||||
Scenario: Editar nombre con caracteres inválidos
|
||||
Given un usuario autenticado
|
||||
When intenta cambiar nombre a "Juan@123!"
|
||||
Then el sistema muestra error "Nombre inválido"
|
||||
And el nombre permanece sin cambios
|
||||
|
||||
Scenario: Cambiar idioma a español
|
||||
Given un usuario con idioma "en"
|
||||
When cambia idioma a "es"
|
||||
Then toda la interfaz se muestra en español
|
||||
And el preference se guarda correctamente
|
||||
```
|
||||
|
||||
### 3.2 Escribir Step Definitions
|
||||
|
||||
Archivo: `features/steps/profile_steps.py`
|
||||
|
||||
```python
|
||||
from behave import given, when, then
|
||||
|
||||
@given('un usuario autenticado con ID "{user_id}"')
|
||||
def step_user_authenticated(context, user_id):
|
||||
context.user_id = user_id
|
||||
context.auth_token = f"token_{user_id}"
|
||||
|
||||
@when('el usuario solicita ver su perfil')
|
||||
def step_get_profile(context):
|
||||
profile_service = ProfileService()
|
||||
context.profile = profile_service.get_profile(context.user_id)
|
||||
|
||||
@then('el sistema retorna datos del perfil')
|
||||
def step_return_profile(context):
|
||||
assert context.profile is not None
|
||||
assert "name" in context.profile
|
||||
|
||||
# ... más steps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 4: Ejecutar el pipeline ARNES
|
||||
|
||||
### Stage: design (architect)
|
||||
- ✅ Crea SDD component
|
||||
- ✅ Crea BDD feature
|
||||
- ✅ Produces `work/artifacts/F-002/architect.md`
|
||||
|
||||
### Stage: build (implementer)
|
||||
- Implementa `UserProfileService`
|
||||
- Escribe step definitions
|
||||
- Ejecuta `behave` para verificar
|
||||
|
||||
### Stage: review_gate (reviewer)
|
||||
- Verifica código coincide con SDD
|
||||
- Verifica BDD coverage
|
||||
|
||||
### Stage: security_gate (security)
|
||||
- Check secrets, dependencies
|
||||
- SAST scan
|
||||
|
||||
### Stage: qa_gate (qa)
|
||||
- Ejecuta BDD scenarios
|
||||
- Verifica trazabilidad
|
||||
|
||||
### Stage: close (leader)
|
||||
- Verifica todos los gates en verde
|
||||
- Produce `leader-close.json`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Archivos generados
|
||||
|
||||
```
|
||||
spec/
|
||||
├── sdd/
|
||||
│ └── components/
|
||||
│ └── user-profile-service.md # Componente SDD
|
||||
│ └── decisions/
|
||||
│ └── 002-almacenamiento-avatar.md # ADR (si aplica)
|
||||
│
|
||||
├── bdd/
|
||||
│ └── features/
|
||||
│ └── profile/
|
||||
│ └── user-profile.feature # Feature BDD
|
||||
|
||||
features/
|
||||
└── steps/
|
||||
└── profile_steps.py # Step definitions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Comandos para ejecutar
|
||||
|
||||
```bash
|
||||
# Verificar estructura
|
||||
./scripts/verify.sh
|
||||
|
||||
# Ejecutar tests BDD para la feature
|
||||
behave spec/bdd/features/profile/user-profile.feature
|
||||
|
||||
# Ejecutar solo scenarios con tag
|
||||
behave spec/bdd/features/profile/user-profile.feature --tags @smoke
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] SDD component creado en `spec/sdd/components/`
|
||||
- [ ] BDD feature creado en `spec/bdd/features/<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
|
||||
## Close rule
|
||||
Feature can be `done` only if:
|
||||
- review approved
|
||||
- security approved
|
||||
- qa approved
|
||||
- documenter evidence exists
|
||||
- publish evidence exists (`publish.json`)
|
||||
- `./scripts/verify.sh` is green
|
||||
|
||||
24
HOWTO.md
24
HOWTO.md
@@ -5,13 +5,15 @@
|
||||
```bash
|
||||
mkdir mi-proyecto && cd mi-proyecto
|
||||
git init
|
||||
# copiar contenido de arnes-fork aquí
|
||||
# instalar/copiAR ARNES dentro de este repo de proyecto
|
||||
/path/to/arnes/scripts/install_into_repo.sh .
|
||||
./scripts/start.sh
|
||||
./scripts/verify.sh
|
||||
python3 scripts/agent_status.py show
|
||||
```
|
||||
|
||||
Después:
|
||||
- Mete tu código dentro de `project/` (o indica otra ruta en el wizard).
|
||||
- Edita `backlog/features.json` (`project`, `description`).
|
||||
- Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`).
|
||||
- Empieza el ciclo SDD (una feature a la vez).
|
||||
@@ -20,7 +22,13 @@ Después:
|
||||
|
||||
## 2) Proyecto ya empezado (brownfield)
|
||||
|
||||
Copia al repo existente solo el core ARNES:
|
||||
Copia al repo existente solo el core ARNES y coloca el código real en `project/` (o usa otra ruta al lanzar el wizard). Recomendado:
|
||||
|
||||
```bash
|
||||
/path/to/arnes/scripts/install_into_repo.sh .
|
||||
```
|
||||
|
||||
Contenido core:
|
||||
- `harness/`
|
||||
- `spec/`
|
||||
- `backlog/`
|
||||
@@ -47,6 +55,17 @@ Crear ticket nuevo (leader/triager, EN caveman):
|
||||
python3 scripts/new_ticket.py
|
||||
```
|
||||
|
||||
Tipos soportados:
|
||||
- `feature`
|
||||
- `fix`
|
||||
- `bug`
|
||||
- `chore`
|
||||
|
||||
Al final del ticket:
|
||||
```bash
|
||||
python3 scripts/publish_ticket.py --feature-id F-001
|
||||
```
|
||||
|
||||
Modelo por tarea:
|
||||
- Config base en `harness/models.profiles.yml`
|
||||
- Reglas en `harness/policies/model-routing.md`
|
||||
@@ -54,5 +73,6 @@ Modelo por tarea:
|
||||
## Reglas operativas mínimas
|
||||
- Máximo una feature en `in_progress`.
|
||||
- `done` requiere gates `review/security/qa` aprobados.
|
||||
- `done` requiere publish final con commit+push del ticket.
|
||||
- Evidencia siempre en `work/artifacts/<feature_id>/`.
|
||||
- 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
|
||||
|
||||
# Puerto por defecto
|
||||
PORT?=8000
|
||||
|
||||
run:
|
||||
@echo "Arrancando ARNES API en http://localhost:$(PORT)/ui/login.html"
|
||||
@echo "Credenciales: alice@example.com / SecurePass123!"
|
||||
python3 -m uvicorn src.main:app --host 0.0.0.0 --port $(PORT)
|
||||
|
||||
run-dev:
|
||||
@echo "Arrancando en modo desarrollo (auto-reload)..."
|
||||
python3 -m uvicorn src.main:app --reload --port $(PORT)
|
||||
|
||||
test:
|
||||
python3 -m unittest discover -s tests
|
||||
.PHONY: verify start ticket publish install clean help
|
||||
|
||||
verify:
|
||||
./scripts/verify.sh
|
||||
@@ -24,22 +9,24 @@ start:
|
||||
ticket:
|
||||
python3 scripts/new_ticket.py
|
||||
|
||||
publish:
|
||||
@test -n "$(FEATURE_ID)" || (echo "Use: make publish FEATURE_ID=F-001" && exit 1)
|
||||
python3 scripts/publish_ticket.py --feature-id $(FEATURE_ID)
|
||||
|
||||
install:
|
||||
@test -n "$(TARGET)" || (echo "Use: make install TARGET=/path/to/project-repo" && exit 1)
|
||||
./scripts/install_into_repo.sh $(TARGET)
|
||||
|
||||
clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "ARNES UI API - Comandos disponibles:"
|
||||
@echo "ARNES template - commands:"
|
||||
@echo ""
|
||||
@echo " make run - Arrancar servidor (puerto 8000)"
|
||||
@echo " make run PORT=8080 - Arrancar en puerto específico"
|
||||
@echo " make run-dev - Arrancar con auto-reload"
|
||||
@echo " make test - Ejecutar tests unitarios"
|
||||
@echo " make verify - Verificar harness"
|
||||
@echo " make start - Wizard de inicio de proyecto"
|
||||
@echo " make ticket - Crear ticket (EN caveman)"
|
||||
@echo " make clean - Limpiar cache"
|
||||
@echo ""
|
||||
@echo "URLs:"
|
||||
@echo " http://localhost:8000/ui/login.html"
|
||||
@echo " make verify - Verify harness core"
|
||||
@echo " make start - First-run wizard"
|
||||
@echo " make ticket - Create ticket (EN caveman)"
|
||||
@echo " make publish FEATURE_ID=.. - Commit and push one ticket"
|
||||
@echo " make install TARGET=.. - Install ARNES into external repo"
|
||||
@echo " make clean - Clean cache files"
|
||||
|
||||
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
|
||||
|
||||
Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad.
|
||||
|
||||
Convención recomendada: el código real del proyecto vive dentro de `project/`.
|
||||
Cada proyecto real debe vivir en **su propio repo git**, distinto del repo fuente de ARNES.
|
||||
Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores.
|
||||
|
||||
---
|
||||
@@ -67,6 +70,7 @@ Permitir que agentes implementen features de forma autónoma **sin perder contro
|
||||
7. `qa_gate` (qa) ✅
|
||||
8. `documentation_gate` (documenter) ✅
|
||||
9. `close` (leader)
|
||||
10. `publish` (leader) ✅
|
||||
|
||||
**Regla:** no hay `done` si cualquier gate falla.
|
||||
|
||||
@@ -86,6 +90,7 @@ Cada agente escribe artefactos en disco:
|
||||
- `work/artifacts/<feature>/security.json`
|
||||
- `work/artifacts/<feature>/qa.json`
|
||||
- `work/artifacts/<feature>/leader-close.json`
|
||||
- `work/artifacts/<feature>/publish.json`
|
||||
|
||||
Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
|
||||
|
||||
@@ -104,29 +109,38 @@ Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
|
||||
|
||||
```text
|
||||
.
|
||||
├── project/ # código real del proyecto
|
||||
│ └── README.md
|
||||
├── README.md
|
||||
├── AGENTS.md
|
||||
├── CHECKPOINTS.md
|
||||
├── harness/
|
||||
│ ├── agents.matrix.yml
|
||||
│ ├── workflow.stages.yml
|
||||
│ ├── models.profiles.yml
|
||||
│ ├── policies/
|
||||
│ │ ├── security.md
|
||||
│ │ ├── quality.md
|
||||
│ │ └── governance.md
|
||||
│ └── contracts/
|
||||
│ ├── handoff.md
|
||||
│ └── evidence.schema.json
|
||||
├── spec/
|
||||
│ ├── product.md
|
||||
│ ├── tech.md
|
||||
│ └── acceptance.md
|
||||
│ ├── acceptance.md
|
||||
│ ├── sdd/
|
||||
│ └── bdd/
|
||||
├── backlog/
|
||||
│ └── features.json
|
||||
├── work/
|
||||
│ ├── current.md
|
||||
│ ├── history.md
|
||||
│ ├── runtime-status.json
|
||||
│ └── artifacts/
|
||||
└── scripts/
|
||||
└── verify.sh
|
||||
├── scripts/
|
||||
│ ├── start.sh
|
||||
│ ├── new_ticket.py
|
||||
│ ├── agent_status.py
|
||||
│ └── verify.sh
|
||||
├── defaults/
|
||||
│ └── flask-skeleton/
|
||||
└── platforms/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -186,16 +200,44 @@ El núcleo no cambia; solo el adaptador.
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
- Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo`
|
||||
- Ejecuta wizard: `./scripts/start.sh`
|
||||
- Crear ticket: `python3 scripts/new_ticket.py`
|
||||
- Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
|
||||
- Guía breve: `HOWTO.md`
|
||||
- Starter pack: `starter-pack/README.md`
|
||||
- Adaptación del template: `TEMPLATE.md`
|
||||
- Layout del repo: `docs/repository-layout.md`
|
||||
- Referencia de scripts: `docs/scripts-reference.md`
|
||||
- Manual Skeleton (uso + mejoras): `docs/skeleton-manual.md`
|
||||
|
||||
## Tipos de tarea / ticket
|
||||
|
||||
`python3 scripts/new_ticket.py` soporta estos tipos:
|
||||
|
||||
- `feature`: nueva capacidad
|
||||
- `fix`: corrección de comportamiento roto
|
||||
- `bug`: incidencia reportada o defecto claro
|
||||
- `chore`: trabajo interno, refactor, setup, mantenimiento
|
||||
|
||||
Además guarda campos estructurados:
|
||||
- `problem`
|
||||
- `goal`
|
||||
- `scope_in`
|
||||
- `scope_out`
|
||||
- `priority`
|
||||
- `risk`
|
||||
- `acceptance`
|
||||
|
||||
Convención recomendada:
|
||||
- usar `feature` para trabajo nuevo visible
|
||||
- usar `fix` o `bug` para reparación
|
||||
- usar `chore` para cambios internos sin valor funcional directo
|
||||
|
||||
## Próximos pasos sugeridos
|
||||
|
||||
1. Definir el backlog inicial del proyecto real.
|
||||
2. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`).
|
||||
3. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`.
|
||||
4. Empezar la primera feature `pending` con pipeline completo.
|
||||
1. Instalar/copiar ARNES en un repo de proyecto real distinto del repo fuente.
|
||||
2. Definir el backlog inicial del proyecto real.
|
||||
3. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`).
|
||||
4. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`.
|
||||
5. Empezar la primera feature `pending` con pipeline completo y terminar con commit+push del ticket.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto
|
||||
|
||||
## 1) Clonar y renombrar contexto
|
||||
- Pon el código real dentro de `project/` (o elige otra ruta en `./scripts/start.sh`).
|
||||
- Ajusta `backlog/features.json` (`project`, `description`).
|
||||
- Crea primeras features reales en `features[]`.
|
||||
|
||||
@@ -8,16 +9,19 @@
|
||||
- Opcional: crea `AGENTS.local.md` con reglas del dominio.
|
||||
- Opcional: crea `scripts/verify.local.sh` con checks propios del stack.
|
||||
- Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`).
|
||||
- Usa tipos de ticket consistentes: `feature`, `fix`, `bug`, `chore`.
|
||||
- Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`.
|
||||
|
||||
## 3) Flujo estándar
|
||||
0. Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo`
|
||||
1. `./scripts/start.sh` (primer uso)
|
||||
2. `python3 scripts/new_ticket.py` (leader/triager)
|
||||
3. `python3 scripts/agent_status.py show`
|
||||
4. Seleccionar 1 feature `pending` y pasarla a `in_progress`
|
||||
5. Implementar con artefactos en `work/artifacts/<feature_id>/`
|
||||
6. Cerrar solo con gates `review/security/qa` + `documenter` aprobados
|
||||
7. `python3 scripts/agent_status.py reset`
|
||||
6. Cerrar con gates `review/security/qa` + `documenter` aprobados
|
||||
7. Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
|
||||
8. `python3 scripts/agent_status.py reset`
|
||||
|
||||
## 4) Contrato de cierre
|
||||
- `status=done` exige:
|
||||
@@ -25,6 +29,7 @@
|
||||
- `security.json` APPROVED
|
||||
- `qa.json` APPROVED
|
||||
- `leader-close.json` APPROVED
|
||||
- `publish.json` PUBLISHED
|
||||
- `./scripts/verify.sh` OK
|
||||
|
||||
## 5) Principio de template
|
||||
|
||||
@@ -11,15 +11,32 @@
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"done"
|
||||
],
|
||||
"valid_types": [
|
||||
"feature",
|
||||
"fix",
|
||||
"bug",
|
||||
"chore"
|
||||
]
|
||||
},
|
||||
"template_feature_schema": {
|
||||
"id": "F-001",
|
||||
"title": "Título de la feature",
|
||||
"description": "Descripción funcional",
|
||||
"type": "feature",
|
||||
"title": "Short ticket title",
|
||||
"problem": "Need change",
|
||||
"goal": "Make flow better",
|
||||
"scope_in": [
|
||||
"Core flow"
|
||||
],
|
||||
"scope_out": [
|
||||
"No redesign"
|
||||
],
|
||||
"priority": "med",
|
||||
"risk": "low",
|
||||
"description": "Problem: ... Goal: ... Scope IN: ... Scope OUT: ... Type: ... Priority: ... Risk: ...",
|
||||
"acceptance": [
|
||||
"Criterio 1",
|
||||
"Criterio 2"
|
||||
"Flow works end to end",
|
||||
"No break old behavior"
|
||||
],
|
||||
"status": "pending",
|
||||
"created_at": "YYYY-MM-DD",
|
||||
|
||||
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]
|
||||
paths = features/
|
||||
format = pretty
|
||||
tags = @F-001
|
||||
|
||||
# Para ejecutar solo smoke tests:
|
||||
# Examples:
|
||||
# behave features/
|
||||
# behave features/ --tags @smoke
|
||||
|
||||
# Para excluir tests lentos:
|
||||
# behave features/ --tags ~@slow
|
||||
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:
|
||||
emoji: "🧭"
|
||||
can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"]
|
||||
cannot_edit: ["src/", "tests/"]
|
||||
cannot_edit: ["project/", "tests/"]
|
||||
responsibilities:
|
||||
- plan
|
||||
- orchestrate
|
||||
- enforce_gates
|
||||
- publish_ticket_changes
|
||||
- close_feature
|
||||
- issue_orders_in_english_caveman
|
||||
|
||||
triager:
|
||||
emoji: "🧩"
|
||||
can_edit: ["backlog/", "work/artifacts/", "spec/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/features.json:status=done"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/features.json:status=done"]
|
||||
responsibilities:
|
||||
- normalize_requests
|
||||
- create_tickets_in_english_caveman
|
||||
@@ -24,20 +25,21 @@ roles:
|
||||
architect:
|
||||
emoji: "🏗️"
|
||||
can_edit: ["spec/", "harness/contracts/", "docs/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/features.json:status"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/features.json:status"]
|
||||
responsibilities:
|
||||
- design
|
||||
- update_contracts
|
||||
|
||||
implementer:
|
||||
emoji: "🛠️"
|
||||
can_edit: ["src/", "tests/", "work/artifacts/"]
|
||||
can_edit: ["project/", "tests/", "work/artifacts/"]
|
||||
cannot_edit:
|
||||
- "backlog/features.json:done"
|
||||
- "work/history.md"
|
||||
- "work/artifacts/*/reviewer.json"
|
||||
- "work/artifacts/*/security.json"
|
||||
- "work/artifacts/*/qa.json"
|
||||
- "work/artifacts/*/publish.json"
|
||||
- "work/artifacts/*/leader-close.json"
|
||||
responsibilities:
|
||||
- implement_feature
|
||||
@@ -47,7 +49,7 @@ roles:
|
||||
reviewer:
|
||||
emoji: "🔍"
|
||||
can_edit: ["work/artifacts/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||
responsibilities:
|
||||
- technical_review
|
||||
- emit_reviewer_verdict
|
||||
@@ -55,7 +57,7 @@ roles:
|
||||
security:
|
||||
emoji: "🔒"
|
||||
can_edit: ["work/artifacts/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||
responsibilities:
|
||||
- sast
|
||||
- dependency_review
|
||||
@@ -65,7 +67,7 @@ roles:
|
||||
qa:
|
||||
emoji: "🧪"
|
||||
can_edit: ["work/artifacts/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/"]
|
||||
responsibilities:
|
||||
- acceptance_traceability
|
||||
- integration_e2e_checks
|
||||
@@ -75,7 +77,7 @@ roles:
|
||||
documenter:
|
||||
emoji: "📚"
|
||||
can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"]
|
||||
cannot_edit: ["src/", "tests/", "backlog/features.json:status"]
|
||||
cannot_edit: ["project/", "tests/", "backlog/features.json:status"]
|
||||
responsibilities:
|
||||
- document_feature_changes
|
||||
- update_user_docs
|
||||
|
||||
@@ -63,9 +63,16 @@ stages:
|
||||
- work/artifacts/<feature_id>/leader-close.json
|
||||
- work/history.md
|
||||
|
||||
- name: publish
|
||||
owner: leader
|
||||
required: true
|
||||
output:
|
||||
- work/artifacts/<feature_id>/publish.json
|
||||
|
||||
close_requirements:
|
||||
- reviewer.json.verdict == "APPROVED"
|
||||
- security.json.verdict == "APPROVED"
|
||||
- qa.json.verdict == "APPROVED"
|
||||
- documenter.md exists
|
||||
- publish.json.verdict == "PUBLISHED"
|
||||
- scripts/verify.sh exit_code == 0
|
||||
|
||||
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
|
||||
uvicorn>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
pytest>=7.0.0
|
||||
httpx>=0.24.0
|
||||
PyJWT>=2.8.0
|
||||
bcrypt>=4.0.0
|
||||
# Template core has no hard runtime deps.
|
||||
# Add project-specific dependencies after running ./scripts/start.sh
|
||||
|
||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
|
||||
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
|
||||
WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml'
|
||||
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
|
||||
VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'}
|
||||
|
||||
DEFAULT_EMOJIS = {
|
||||
'leader': '🧭',
|
||||
@@ -26,6 +28,7 @@ GATE_FILES = {
|
||||
'security': 'security.json',
|
||||
'qa': 'qa.json',
|
||||
'documenter': 'documenter.md',
|
||||
'publish': 'publish.json',
|
||||
'leader': 'leader-close.json',
|
||||
}
|
||||
|
||||
@@ -60,6 +63,28 @@ def load_role_emojis():
|
||||
return emojis
|
||||
|
||||
|
||||
def load_roles():
|
||||
roles = []
|
||||
if not MATRIX_PATH.exists():
|
||||
return roles
|
||||
for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines():
|
||||
match_role = re.match(r'^ ([a-z_]+):\s*$', line)
|
||||
if match_role:
|
||||
roles.append(match_role.group(1))
|
||||
return roles
|
||||
|
||||
|
||||
def load_stage_names():
|
||||
stages = []
|
||||
if not WORKFLOW_PATH.exists():
|
||||
return stages
|
||||
for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines():
|
||||
match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line)
|
||||
if match_stage:
|
||||
stages.append(match_stage.group(1))
|
||||
return stages
|
||||
|
||||
|
||||
def default_status():
|
||||
return {
|
||||
'feature_id': None,
|
||||
@@ -99,7 +124,8 @@ def gate_status(feature_id):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present'
|
||||
wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED'
|
||||
gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present'
|
||||
except Exception:
|
||||
gates[gate] = 'invalid'
|
||||
return gates
|
||||
@@ -115,10 +141,25 @@ def render_gate(gate, state, emojis):
|
||||
label = {
|
||||
'leader': 'close',
|
||||
'documenter': 'docs',
|
||||
'publish': 'publish',
|
||||
}.get(gate, gate)
|
||||
return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}"
|
||||
|
||||
|
||||
def validate_runtime_args(args):
|
||||
roles = set(load_roles()) or set(DEFAULT_EMOJIS)
|
||||
stages = set(load_stage_names()) | {'idle'}
|
||||
|
||||
if args.agent is not None and args.agent not in roles:
|
||||
raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}")
|
||||
if args.next_agent is not None and args.next_agent not in roles:
|
||||
raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}")
|
||||
if args.stage is not None and args.stage not in stages:
|
||||
raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}")
|
||||
if args.state is not None and args.state not in VALID_RUNTIME_STATES:
|
||||
raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}")
|
||||
|
||||
|
||||
def show_status():
|
||||
status = load_status()
|
||||
emojis = load_role_emojis()
|
||||
@@ -141,7 +182,7 @@ def show_status():
|
||||
print()
|
||||
print('Gates')
|
||||
if gates:
|
||||
for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']:
|
||||
for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']:
|
||||
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
|
||||
else:
|
||||
print(' — Sin feature activa —')
|
||||
@@ -162,6 +203,7 @@ def show_status():
|
||||
|
||||
|
||||
def set_status(args):
|
||||
validate_runtime_args(args)
|
||||
status = load_status()
|
||||
if args.feature_id is not None:
|
||||
status['feature_id'] = args.feature_id or None
|
||||
|
||||
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]
|
||||
BACKLOG = ROOT / 'backlog' / 'features.json'
|
||||
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
|
||||
LEVEL_CHOICES = ('low', 'med', 'high')
|
||||
|
||||
|
||||
def ask(prompt, default=''):
|
||||
@@ -12,10 +14,23 @@ def ask(prompt, default=''):
|
||||
return value if value else default
|
||||
|
||||
|
||||
def ask_choice(prompt, choices, default):
|
||||
while True:
|
||||
value = ask(prompt, default).lower()
|
||||
if value in choices:
|
||||
return value
|
||||
print(f"Invalid value. Use one of: {', '.join(choices)}")
|
||||
|
||||
|
||||
def ask_list(prompt, default_csv=''):
|
||||
raw = ask(prompt, default_csv)
|
||||
return [item.strip() for item in raw.split(',') if item.strip()]
|
||||
|
||||
|
||||
def next_id(features):
|
||||
nums = []
|
||||
for f in features:
|
||||
fid = str(f.get('id', ''))
|
||||
for feature in features:
|
||||
fid = str(feature.get('id', ''))
|
||||
if fid.startswith('F-') and fid[2:].isdigit():
|
||||
nums.append(int(fid[2:]))
|
||||
return f"F-{(max(nums) + 1) if nums else 1:03d}"
|
||||
@@ -26,14 +41,14 @@ def main():
|
||||
features = data.get('features', [])
|
||||
|
||||
print('Create ticket (English caveman style).')
|
||||
ttype = ask('Type (feature/fix/bug/chore)', 'feature')
|
||||
title = ask('Title (short EN)', f'{ttype.capitalize()} TODO')
|
||||
ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
|
||||
title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
|
||||
problem = ask('Problem (short EN)', 'Need change')
|
||||
goal = ask('Goal (short EN)', 'Make flow better')
|
||||
scope_in = ask('Scope IN (comma list EN)', 'Core flow')
|
||||
scope_out = ask('Scope OUT (comma list EN)', 'No redesign')
|
||||
risk = ask('Risk (low/med/high)', 'low')
|
||||
priority = ask('Priority (low/med/high)', 'med')
|
||||
scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
|
||||
scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
|
||||
risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
|
||||
priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
|
||||
|
||||
print('Acceptance bullets (EN caveman). Empty line to end.')
|
||||
acceptance = []
|
||||
@@ -47,29 +62,38 @@ def main():
|
||||
acceptance = [
|
||||
'Flow works end to end',
|
||||
'No break old behavior',
|
||||
'verify.sh is green'
|
||||
'verify.sh is green',
|
||||
]
|
||||
|
||||
fid = next_id(features)
|
||||
desc = (
|
||||
f"Problem: {problem}. "
|
||||
f"Goal: {goal}. "
|
||||
f"Scope IN: {scope_in}. "
|
||||
f"Scope OUT: {scope_out}. "
|
||||
f"Type: {ttype}. Priority: {priority}. Risk: {risk}."
|
||||
f"Scope IN: {', '.join(scope_in) or 'none'}. "
|
||||
f"Scope OUT: {', '.join(scope_out) or 'none'}. "
|
||||
f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
|
||||
)
|
||||
|
||||
features.append({
|
||||
'id': fid,
|
||||
'type': ticket_type,
|
||||
'title': title,
|
||||
'problem': problem,
|
||||
'goal': goal,
|
||||
'scope_in': scope_in,
|
||||
'scope_out': scope_out,
|
||||
'priority': priority,
|
||||
'risk': risk,
|
||||
'description': desc,
|
||||
'acceptance': acceptance,
|
||||
'status': 'pending',
|
||||
'created_at': str(date.today()),
|
||||
'gates': {'review': False, 'security': False, 'qa': False}
|
||||
'gates': {'review': False, 'security': False, 'qa': False},
|
||||
})
|
||||
|
||||
data['features'] = features
|
||||
rules = data.setdefault('rules', {})
|
||||
rules.setdefault('valid_types', list(TYPE_CHOICES))
|
||||
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
print(f'Created {fid}: {title}')
|
||||
|
||||
|
||||
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 "Mode: use this template in a new repo or copy core ARNES into an existing repo."
|
||||
|
||||
echo "Mode: clone arnes-fork, put your app folder inside, run this wizard."
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "No git repo detected. Initializing local git repository..."
|
||||
git init >/dev/null
|
||||
fi
|
||||
|
||||
PROJECT_NAME="$(ask 'Project name' 'my-project')"
|
||||
PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')"
|
||||
APP_DIR="$(ask 'App directory (relative)' 'app')"
|
||||
APP_DIR="$(ask 'App directory (relative)' 'project')"
|
||||
|
||||
STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')"
|
||||
if [ "$STACK_CHOICE" = "2" ]; then
|
||||
@@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')"
|
||||
ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')"
|
||||
|
||||
mkdir -p "$APP_DIR"
|
||||
[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" <<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
|
||||
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
|
||||
cp -n defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css" || true
|
||||
cp -n defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png" || true
|
||||
[ -f "$APP_DIR/static/css/normalize.css" ] || cp defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css"
|
||||
[ -f "$APP_DIR/static/css/skeleton.css" ] || cp defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css"
|
||||
[ -f "$APP_DIR/static/images/favicon.png" ] || cp defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png"
|
||||
fi
|
||||
|
||||
cat > harness/project.config.json <<JSON
|
||||
@@ -82,7 +96,7 @@ APP_DIR=$(python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
cfg=json.loads(Path('harness/project.config.json').read_text())
|
||||
print(cfg.get('app_dir','app'))
|
||||
print(cfg.get('app_dir','project'))
|
||||
PY
|
||||
)
|
||||
TEST_CMD=$(python3 - <<'PY'
|
||||
@@ -126,25 +140,34 @@ import json
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
|
||||
b=Path('backlog/features.json')
|
||||
data=json.loads(b.read_text(encoding='utf-8'))
|
||||
data['project']='$PROJECT_NAME'
|
||||
data['description']='$PROJECT_DESC'
|
||||
features=data.get('features',[])
|
||||
b = Path('backlog/features.json')
|
||||
data = json.loads(b.read_text(encoding='utf-8'))
|
||||
data['project'] = '$PROJECT_NAME'
|
||||
data['description'] = '$PROJECT_DESC'
|
||||
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:
|
||||
features.append({
|
||||
'id':'F-001',
|
||||
'title':'Bootstrap ARNES on project',
|
||||
'description':'Setup ARNES pipeline and run first complete feature cycle.',
|
||||
'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}
|
||||
'id': 'F-001',
|
||||
'type': 'chore',
|
||||
'title': 'Bootstrap ARNES on project',
|
||||
'problem': 'Need base workflow and control',
|
||||
'goal': 'Make ARNES ready on this repo',
|
||||
'scope_in': ['Harness setup', 'Runtime status', 'First verify cycle'],
|
||||
'scope_out': ['Business feature work', 'Product redesign'],
|
||||
'priority': 'med',
|
||||
'risk': 'low',
|
||||
'description': 'Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.',
|
||||
'acceptance': ['verify.sh 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
|
||||
b.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
||||
data['features'] = features
|
||||
b.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
PY
|
||||
|
||||
cat > work/current.md <<EOF
|
||||
@@ -169,5 +192,6 @@ echo "Done. Project configured."
|
||||
echo "- Config: harness/project.config.json"
|
||||
echo "- Local checks: scripts/verify.local.sh"
|
||||
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 "- 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=(
|
||||
"AGENTS.md"
|
||||
"CHECKPOINTS.md"
|
||||
"README.md"
|
||||
"HOWTO.md"
|
||||
"TEMPLATE.md"
|
||||
"docs/repository-layout.md"
|
||||
"docs/scripts-reference.md"
|
||||
"harness/agents.matrix.yml"
|
||||
"harness/workflow.stages.yml"
|
||||
"harness/policies/governance.md"
|
||||
@@ -31,14 +36,24 @@ required=(
|
||||
"spec/product.md"
|
||||
"spec/tech.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"
|
||||
"work/current.md"
|
||||
"work/history.md"
|
||||
"work/runtime-status.json"
|
||||
"scripts/agent_status.py"
|
||||
"scripts/new_ticket.py"
|
||||
"scripts/publish_ticket.py"
|
||||
"scripts/install_into_repo.sh"
|
||||
"scripts/start.sh"
|
||||
"platforms/pi/README.md"
|
||||
"platforms/opencode/README.md"
|
||||
)
|
||||
|
||||
for f in "${required[@]}"; do
|
||||
@@ -50,6 +65,20 @@ for f in "${required[@]}"; do
|
||||
fi
|
||||
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 "── 2) Validando backlog + gates ───────────────────────"
|
||||
python3 - <<'PY'
|
||||
@@ -59,6 +88,7 @@ import sys
|
||||
|
||||
root = pathlib.Path('.')
|
||||
path = root / 'backlog' / 'features.json'
|
||||
level_choices = {'low', 'med', 'high'}
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
@@ -66,7 +96,9 @@ except Exception as e:
|
||||
print(f"[FAIL] backlog/features.json inválido: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"]))
|
||||
rules = data.get('rules', {})
|
||||
valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"]))
|
||||
valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"]))
|
||||
features = data.get('features', [])
|
||||
if not isinstance(features, list):
|
||||
print('[FAIL] features debe ser una lista')
|
||||
@@ -85,25 +117,65 @@ if len(in_progress) > 1:
|
||||
for f in features:
|
||||
fid = str(f.get('id', '')).strip()
|
||||
status = f.get('status')
|
||||
if status not in valid:
|
||||
title = str(f.get('title', '')).strip()
|
||||
acceptance = f.get('acceptance')
|
||||
gates = f.get('gates', {})
|
||||
|
||||
if not fid:
|
||||
print('[FAIL] Hay una feature sin id')
|
||||
sys.exit(1)
|
||||
if not title:
|
||||
print(f"[FAIL] Feature {fid} sin title")
|
||||
sys.exit(1)
|
||||
if status not in valid_status:
|
||||
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
|
||||
sys.exit(1)
|
||||
if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance):
|
||||
print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía")
|
||||
sys.exit(1)
|
||||
|
||||
ticket_type = f.get('type')
|
||||
if ticket_type is not None and ticket_type not in valid_types:
|
||||
print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}")
|
||||
sys.exit(1)
|
||||
|
||||
for field in ('priority', 'risk'):
|
||||
value = f.get(field)
|
||||
if value is not None and value not in level_choices:
|
||||
print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}")
|
||||
sys.exit(1)
|
||||
|
||||
for field in ('scope_in', 'scope_out'):
|
||||
value = f.get(field)
|
||||
if value is not None:
|
||||
if not isinstance(value, list) or any(not str(item).strip() for item in value):
|
||||
print(f"[FAIL] Feature {fid} tiene {field} inválido")
|
||||
sys.exit(1)
|
||||
|
||||
if gates:
|
||||
for gate_name in ('review', 'security', 'qa'):
|
||||
gate_value = gates.get(gate_name)
|
||||
if not isinstance(gate_value, bool):
|
||||
print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido")
|
||||
sys.exit(1)
|
||||
|
||||
if status == 'done':
|
||||
d = root / 'work' / 'artifacts' / fid
|
||||
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md']
|
||||
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.json']
|
||||
missing = [name for name in req if not (d / name).is_file()]
|
||||
if missing:
|
||||
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
expected = {
|
||||
'reviewer.json': 'reviewer',
|
||||
'security.json': 'security',
|
||||
'qa.json': 'qa',
|
||||
'leader-close.json': 'leader',
|
||||
'reviewer.json': ('reviewer', 'APPROVED'),
|
||||
'security.json': ('security', 'APPROVED'),
|
||||
'qa.json': ('qa', 'APPROVED'),
|
||||
'leader-close.json': ('leader', 'APPROVED'),
|
||||
'publish.json': ('leader', 'PUBLISHED'),
|
||||
}
|
||||
for filename, agent in expected.items():
|
||||
for filename, rule in expected.items():
|
||||
agent, verdict = rule
|
||||
try:
|
||||
obj = json.loads((d / filename).read_text(encoding='utf-8'))
|
||||
except Exception as e:
|
||||
@@ -113,8 +185,11 @@ for f in features:
|
||||
if obj.get('agent') != agent:
|
||||
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
|
||||
sys.exit(1)
|
||||
if obj.get('verdict') != 'APPROVED':
|
||||
print(f"[FAIL] {fid}/{filename} no está APPROVED")
|
||||
if obj.get('verdict') != verdict:
|
||||
print(f"[FAIL] {fid}/{filename} no está {verdict}")
|
||||
sys.exit(1)
|
||||
if filename == 'publish.json' and obj.get('pushed') is not True:
|
||||
print(f"[FAIL] {fid}/{filename} debe tener pushed=true")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] backlog válido ({len(features)} features)")
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Este directorio contiene especificaciones BDD en formato Gherkin.
|
||||
Los archivos `.feature` sirven como especificación ejecutable.
|
||||
Este directorio contiene las especificaciones BDD fuente en formato Gherkin.
|
||||
|
||||
Separación recomendada:
|
||||
- `spec/bdd/features/` = source-of-truth de escenarios
|
||||
- `features/` = assets ejecutables del runner (steps, config)
|
||||
|
||||
### naming conventions
|
||||
|
||||
```
|
||||
features/
|
||||
```text
|
||||
spec/bdd/features/
|
||||
├── <domain>/
|
||||
│ ├── <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`
|
||||
|
||||
```
|
||||
features/
|
||||
├── auth/
|
||||
│ ├── login.feature
|
||||
│ └── registration.feature
|
||||
├── dashboard/
|
||||
│ └── dashboard.feature
|
||||
├── common/
|
||||
│ ├── navigation.feature
|
||||
│ └── error-handling.feature
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Tags comunes
|
||||
|
||||
Usar estos tags en todos los features:
|
||||
|
||||
| Tag | Descripción |
|
||||
|-----|-------------|
|
||||
| `@F-XXX` | Link a feature ID del backlog |
|
||||
| `@smoke` | Test crítico |
|
||||
| `@regression` | Regresión |
|
||||
|
||||
## Example
|
||||
|
||||
```gherkin
|
||||
@F-001 @auth @smoke
|
||||
Feature: Inicio de sesión
|
||||
|
||||
Como usuario registrado
|
||||
Quiero iniciar sesión con mis credenciales
|
||||
Para acceder a mi cuenta personal
|
||||
|
||||
@positive
|
||||
Scenario: Login exitoso con credenciales válidas
|
||||
Given un usuario con email "user@example.com" y password "Password123"
|
||||
And el usuario no tiene sesión activa
|
||||
When el usuario ingresa email "user@example.com"
|
||||
And ingresa password "Password123"
|
||||
And presiona el botón "Iniciar sesión"
|
||||
Then el sistema redirige al dashboard
|
||||
And muestra mensaje de bienvenida
|
||||
|
||||
@negative
|
||||
Scenario: Login fallido con password incorrecto
|
||||
Given un usuario con email "user@example.com" y password "Password123"
|
||||
When el usuario ingresa email "user@example.com"
|
||||
And ingresa password "WrongPassword"
|
||||
And presiona el botón "Iniciar sesión"
|
||||
Then el sistema muestra mensaje de error "Credenciales inválidas"
|
||||
And permanece en la página de login
|
||||
```
|
||||
Use tags like:
|
||||
- `@F-001`
|
||||
- `@smoke`
|
||||
- `@regression`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,10 +25,13 @@ spec/
|
||||
│ ├── architecture.md
|
||||
│ ├── components/
|
||||
│ └── decisions/
|
||||
└── bdd/ # Behavior Driven Development
|
||||
└── bdd/ # Behavior Driven Development source-of-truth
|
||||
├── README.md
|
||||
├── features/
|
||||
└── step_definitions/
|
||||
└── features/
|
||||
|
||||
features/ # optional executable BDD runner assets
|
||||
├── behave.ini
|
||||
└── steps/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -152,6 +155,11 @@ spec/bdd/features/
|
||||
│ └── purchase.feature
|
||||
└── common/
|
||||
└── error-handling.feature
|
||||
|
||||
features/
|
||||
├── behave.ini
|
||||
└── steps/
|
||||
└── login_steps.py
|
||||
```
|
||||
|
||||
### Tags para trazabilidad
|
||||
@@ -224,8 +232,10 @@ Tags disponibles:
|
||||
|
||||
```bash
|
||||
# Estructura
|
||||
spec/bdd/features/
|
||||
└── login.feature
|
||||
|
||||
features/
|
||||
├── login.feature
|
||||
└── steps/
|
||||
└── login_steps.py
|
||||
|
||||
@@ -237,8 +247,10 @@ behave features/
|
||||
|
||||
```bash
|
||||
# Estructura
|
||||
spec/bdd/features/
|
||||
└── login.feature
|
||||
|
||||
features/
|
||||
├── login.feature
|
||||
└── step_definitions/
|
||||
└── login_steps.js
|
||||
|
||||
|
||||
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)
|
||||
1. Crea repo vacío.
|
||||
2. Copia el template ARNES.
|
||||
2. Instala el core ARNES desde otro repo fuente (`scripts/install_into_repo.sh`).
|
||||
3. Ajusta `backlog/features.json` (`project`, `description`).
|
||||
4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature.
|
||||
4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature (`type=chore`).
|
||||
5. Ejecuta:
|
||||
- `./scripts/verify.sh`
|
||||
- `python3 scripts/agent_status.py show`
|
||||
|
||||
## B) Proyecto ya empezado (brownfield)
|
||||
1. Copia **solo** carpetas core ARNES: `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`.
|
||||
1. Instala **solo** el core ARNES dentro del repo existente.
|
||||
2. Mantén tu código actual intacto.
|
||||
3. Añade checks del dominio en `scripts/verify.local.sh`.
|
||||
4. Define features reales del proyecto en `backlog/features.json`.
|
||||
@@ -22,5 +22,7 @@ Este pack sirve para arrancar ARNES en 2 escenarios:
|
||||
|
||||
## Reglas mínimas
|
||||
- 1 sola feature en `in_progress`.
|
||||
- Tipos válidos: `feature`, `fix`, `bug`, `chore`.
|
||||
- `done` requiere gates: `review/security/qa`.
|
||||
- `done` requiere commit+push final del ticket.
|
||||
- Evidencia en `work/artifacts/<feature_id>/`.
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
{
|
||||
"id": "F-001",
|
||||
"title": "Bootstrap de proyecto con ARNES",
|
||||
"description": "Configurar pipeline SDD en este repositorio y validar primer ciclo completo.",
|
||||
"type": "chore",
|
||||
"title": "Bootstrap ARNES on project",
|
||||
"problem": "Need base workflow and control",
|
||||
"goal": "Make ARNES ready on this repo",
|
||||
"scope_in": [
|
||||
"Harness setup",
|
||||
"Runtime status",
|
||||
"First verify cycle"
|
||||
],
|
||||
"scope_out": [
|
||||
"Business feature work",
|
||||
"Product redesign"
|
||||
],
|
||||
"priority": "med",
|
||||
"risk": "low",
|
||||
"description": "Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.",
|
||||
"acceptance": [
|
||||
"verify.sh en verde",
|
||||
"runtime-status operativo",
|
||||
"primera feature cerrada con gates"
|
||||
"verify.sh is green",
|
||||
"runtime status works",
|
||||
"first feature closes with gates"
|
||||
],
|
||||
"status": "pending",
|
||||
"created_at": "YYYY-MM-DD",
|
||||
|
||||
@@ -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