refactor: make ARNES external-repo based with ticket publish flow

This commit is contained in:
rikrdo
2026-05-18 00:26:32 +02:00
parent 3ff9b70e4c
commit b396b6d3c9
101 changed files with 810 additions and 6140 deletions

View File

@@ -1,9 +1,9 @@
# AGENTS.local.md (ejemplo opcional) # AGENTS.local.md (optional example)
Este archivo define reglas específicas del proyecto actual. Use this file for project-specific rules only.
## Ejemplo ## Example
- Stack: FastAPI + PostgreSQL - App dir: `project/`
- Deploy: Kubernetes - Deploy target: staging Kubernetes cluster
- Regla extra: toda migración requiere evidencia en `work/artifacts/<id>/db.md` - Extra rule: DB changes require `work/artifacts/<id>/db.md`
- Regla extra: `scripts/verify.local.sh` debe ejecutar `alembic check` y `pytest -m smoke` - Extra rule: `scripts/verify.local.sh` must run smoke tests

View File

@@ -3,12 +3,13 @@
Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso. Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso.
## Arranque obligatorio ## Arranque obligatorio
1. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`. 1. Usar ARNES dentro de un repo de proyecto real, no dentro del repo fuente de ARNES.
2. Leer `work/current.md`. 2. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`.
3. Leer `backlog/features.json` y seleccionar **una** feature `pending`. 3. Leer `work/current.md`.
4. Ejecutar `./scripts/verify.sh`. 4. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
5. Mostrar estado runtime: `python3 scripts/agent_status.py show`. 5. Ejecutar `./scripts/verify.sh`.
6. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`. 6. Mostrar estado runtime: `python3 scripts/agent_status.py show`.
7. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
## Ticket creation policy ## Ticket creation policy
- Tickets are created by `leader` (or `triager`) only. - Tickets are created by `leader` (or `triager`) only.
@@ -30,6 +31,7 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en
- `implementer` nunca marca `done`. - `implementer` nunca marca `done`.
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`. - `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
- `done` requiere evidencia de `documenter`: `work/artifacts/<feature_id>/documenter.md`. - `done` requiere evidencia de `documenter`: `work/artifacts/<feature_id>/documenter.md`.
- `done` requiere publish final con commit+push del ticket: `work/artifacts/<feature_id>/publish.json`.
- Si `verify.sh` falla, no se cierra la feature. - Si `verify.sh` falla, no se cierra la feature.
## Modelo por tarea (token-aware) ## Modelo por tarea (token-aware)
@@ -37,6 +39,11 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en
- Routing config: `harness/models.profiles.yml` - Routing config: `harness/models.profiles.yml`
- Rules: `harness/policies/model-routing.md` - Rules: `harness/policies/model-routing.md`
## Git publish por ticket
- Al terminar una feature/ticket, `leader` debe ejecutar:
- `python3 scripts/publish_ticket.py --feature-id F-123`
- Esto crea commit + push del ticket y deja evidencia en `work/artifacts/<feature_id>/publish.json`.
## Extensión por proyecto (overlay) ## Extensión por proyecto (overlay)
- Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual. - Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual.
- Opcional: `scripts/verify.local.sh` para checks de dominio. - Opcional: `scripts/verify.local.sh` para checks de dominio.

View File

@@ -1,11 +1,13 @@
# CHECKPOINTS # CHECKPOINTS
## C1 — Estructura ## C1 — Estructura
- [ ] Existe `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. - [ ] Existe `project/`, `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`.
- [ ] `project/README.md` existe como placeholder mínimo.
## C2 — Estado ## C2 — Estado
- [ ] Máximo una feature en `in_progress`. - [ ] Máximo una feature en `in_progress`.
- [ ] Estados válidos en backlog. - [ ] Estados válidos en backlog.
- [ ] Tipos de ticket válidos en backlog.
- [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`. - [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`.
## C3 — Gates ## C3 — Gates
@@ -14,6 +16,7 @@
- [ ] Toda feature `done` tiene `qa.json` aprobado. - [ ] Toda feature `done` tiene `qa.json` aprobado.
- [ ] Toda feature `done` tiene `leader-close.json` válido. - [ ] Toda feature `done` tiene `leader-close.json` válido.
- [ ] Toda feature `done` tiene `documenter.md`. - [ ] Toda feature `done` tiene `documenter.md`.
- [ ] Toda feature `done` tiene `publish.json` con commit+push del ticket.
## C4 — Verificación ## C4 — Verificación
- [ ] `./scripts/verify.sh` termina en OK. - [ ] `./scripts/verify.sh` termina en OK.

View File

@@ -1,228 +1,41 @@
# Cómo crear una Feature con SDD y BDD # HOWTO-FEATURE — Crear una feature con SDD y BDD
Guía paso a paso para crear una feature usando System Design Document y Behavior Driven Development. ## Flujo corto
1. Crear ticket en backlog (`python3 scripts/new_ticket.py`)
2. `design` (architect)
3. `build` (implementer)
4. `review/security/qa`
5. `documentation_gate`
6. `close`
7. `publish` (`python3 scripts/publish_ticket.py --feature-id F-001`)
--- ## Artefactos esperados
- `work/artifacts/<feature_id>/triage.md` (opcional)
- `work/artifacts/<feature_id>/architect.md` (opcional)
- `work/artifacts/<feature_id>/implementer.md`
- `work/artifacts/<feature_id>/reviewer.json`
- `work/artifacts/<feature_id>/security.json`
- `work/artifacts/<feature_id>/qa.json`
- `work/artifacts/<feature_id>/documenter.md`
- `work/artifacts/<feature_id>/leader-close.json`
- `work/artifacts/<feature_id>/publish.json`
## 📋 Flujo general ## Ticket style
- English caveman
- short title
- short acceptance bullets
- clear scope in/out
``` ## BDD notes
1. Analizar la feature del backlog - Put `.feature` files in `spec/bdd/features/`
- Put steps in `features/steps/`
2. Crear SPEC/BBD (architect) - Use tags like `@F-001`, `@smoke`, `@regression`
3. Crear/actualizar SDD (architect)
4. Generar código + tests (implementer)
5. Review, Security, QA gates
6. Cerrar feature
```
--- ## Close rule
Feature can be `done` only if:
## Paso 1: Analizar del Backlog - review approved
- security approved
Ejemplo: F-002 "Gestión de Perfil de Usuario" - qa approved
- documenter evidence exists
```json - publish evidence exists (`publish.json`)
{ - `./scripts/verify.sh` is green
"id": "F-002",
"title": "Gestión de Perfil de Usuario",
"description": "El usuario puede ver y editar su perfil (nombre, avatar, preferencias).",
"acceptance": [
"Usuario puede ver su perfil",
"Usuario puede editar nombre y avatar",
"Usuario puede cambiar preferencias de idioma",
"Validación de datos en todos los campos"
]
}
```
---
## Paso 2: Crear SDD (System Design Document)
### 2.1 Crear componente
Archivo: `spec/sdd/components/user-profile-service.md`
```markdown
# Component: UserProfileService
## Responsabilidad
Gestionar el perfil de usuario: consulta, actualización de datos básicos y preferencias.
## Tipo
- [x] Microservicio
## Interfaces
### API REST
```
GET /api/v1/users/{user_id}/profile
Output: { "id", "name", "avatar_url", "language", "created_at" }
PUT /api/v1/users/{user_id}/profile
Input: { "name": string, "avatar_url": string, "language": string }
Output: { "id", "name", "avatar_url", "language", "updated_at" }
```
## Validaciones
- name: 2-50 caracteres, sin caracteres especiales
- avatar_url: URL válida (http/https)
- language: enum ['en', 'es', 'fr', 'de']
```
### 2.2 Crear ADR (si hay decisión técnica)
Archivo: `spec/sdd/decisions/002-almacenamiento-avatar.md`
---
## Paso 3: Crear BDD (Behavior Driven Development)
### 3.1 Crear archivo .feature
Archivo: `spec/bdd/features/profile/user-profile.feature`
```gherkin
@F-002 @profile
Feature: Gestión de Perfil de Usuario
Como usuario autenticado
Quiero gestionar mi perfil
Para mantener mis datos actualizados
@smoke
Scenario: Ver perfil de usuario
Given un usuario autenticado con ID "user-123"
When el usuario solicita ver su perfil
Then el sistema retorna datos del perfil
And incluye nombre, avatar y preferencias
Scenario: Editar nombre del perfil
Given un usuario autenticado con ID "user-123"
And el perfil tiene nombre "Juan"
When el usuario actualiza su nombre a "Pedro"
Then el perfil muestra nombre "Pedro"
And la fecha de actualización se registra
@negative
Scenario: Editar nombre con caracteres inválidos
Given un usuario autenticado
When intenta cambiar nombre a "Juan@123!"
Then el sistema muestra error "Nombre inválido"
And el nombre permanece sin cambios
Scenario: Cambiar idioma a español
Given un usuario con idioma "en"
When cambia idioma a "es"
Then toda la interfaz se muestra en español
And el preference se guarda correctamente
```
### 3.2 Escribir Step Definitions
Archivo: `features/steps/profile_steps.py`
```python
from behave import given, when, then
@given('un usuario autenticado con ID "{user_id}"')
def step_user_authenticated(context, user_id):
context.user_id = user_id
context.auth_token = f"token_{user_id}"
@when('el usuario solicita ver su perfil')
def step_get_profile(context):
profile_service = ProfileService()
context.profile = profile_service.get_profile(context.user_id)
@then('el sistema retorna datos del perfil')
def step_return_profile(context):
assert context.profile is not None
assert "name" in context.profile
# ... más steps
```
---
## Paso 4: Ejecutar el pipeline ARNES
### Stage: design (architect)
- ✅ Crea SDD component
- ✅ Crea BDD feature
- ✅ Produces `work/artifacts/F-002/architect.md`
### Stage: build (implementer)
- Implementa `UserProfileService`
- Escribe step definitions
- Ejecuta `behave` para verificar
### Stage: review_gate (reviewer)
- Verifica código coincide con SDD
- Verifica BDD coverage
### Stage: security_gate (security)
- Check secrets, dependencies
- SAST scan
### Stage: qa_gate (qa)
- Ejecuta BDD scenarios
- Verifica trazabilidad
### Stage: close (leader)
- Verifica todos los gates en verde
- Produce `leader-close.json`
---
## 📁 Archivos generados
```
spec/
├── sdd/
│ └── components/
│ └── user-profile-service.md # Componente SDD
│ └── decisions/
│ └── 002-almacenamiento-avatar.md # ADR (si aplica)
├── bdd/
│ └── features/
│ └── profile/
│ └── user-profile.feature # Feature BDD
features/
└── steps/
└── profile_steps.py # Step definitions
```
---
## 🚀 Comandos para ejecutar
```bash
# Verificar estructura
./scripts/verify.sh
# Ejecutar tests BDD para la feature
behave spec/bdd/features/profile/user-profile.feature
# Ejecutar solo scenarios con tag
behave spec/bdd/features/profile/user-profile.feature --tags @smoke
```
---
## Checklist
- [ ] SDD component creado en `spec/sdd/components/`
- [ ] BDD feature creado en `spec/bdd/features/<domain>/`
- [ ] Steps implementados en `features/steps/`
- [ ] Todos los scenarios tienen Given/When/Then
- [ ] Tags `@F-XXX` presentes en feature
- [ ] SDD/BDD linkeados en artefacto architect

View File

@@ -5,13 +5,15 @@
```bash ```bash
mkdir mi-proyecto && cd mi-proyecto mkdir mi-proyecto && cd mi-proyecto
git init git init
# copiar contenido de arnes-fork aquí # instalar/copiAR ARNES dentro de este repo de proyecto
/path/to/arnes/scripts/install_into_repo.sh .
./scripts/start.sh ./scripts/start.sh
./scripts/verify.sh ./scripts/verify.sh
python3 scripts/agent_status.py show python3 scripts/agent_status.py show
``` ```
Después: Después:
- Mete tu código dentro de `project/` (o indica otra ruta en el wizard).
- Edita `backlog/features.json` (`project`, `description`). - Edita `backlog/features.json` (`project`, `description`).
- Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`). - Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`).
- Empieza el ciclo SDD (una feature a la vez). - Empieza el ciclo SDD (una feature a la vez).
@@ -20,7 +22,13 @@ Después:
## 2) Proyecto ya empezado (brownfield) ## 2) Proyecto ya empezado (brownfield)
Copia al repo existente solo el core ARNES: Copia al repo existente solo el core ARNES y coloca el código real en `project/` (o usa otra ruta al lanzar el wizard). Recomendado:
```bash
/path/to/arnes/scripts/install_into_repo.sh .
```
Contenido core:
- `harness/` - `harness/`
- `spec/` - `spec/`
- `backlog/` - `backlog/`
@@ -47,6 +55,17 @@ Crear ticket nuevo (leader/triager, EN caveman):
python3 scripts/new_ticket.py python3 scripts/new_ticket.py
``` ```
Tipos soportados:
- `feature`
- `fix`
- `bug`
- `chore`
Al final del ticket:
```bash
python3 scripts/publish_ticket.py --feature-id F-001
```
Modelo por tarea: Modelo por tarea:
- Config base en `harness/models.profiles.yml` - Config base en `harness/models.profiles.yml`
- Reglas en `harness/policies/model-routing.md` - Reglas en `harness/policies/model-routing.md`
@@ -54,5 +73,6 @@ Modelo por tarea:
## Reglas operativas mínimas ## Reglas operativas mínimas
- Máximo una feature en `in_progress`. - Máximo una feature en `in_progress`.
- `done` requiere gates `review/security/qa` aprobados. - `done` requiere gates `review/security/qa` aprobados.
- `done` requiere publish final con commit+push del ticket.
- Evidencia siempre en `work/artifacts/<feature_id>/`. - Evidencia siempre en `work/artifacts/<feature_id>/`.
- Si `verify.sh` falla, no se cierra la feature. - Si `verify.sh` falla, no se cierra la feature.

View File

@@ -1,19 +1,4 @@
.PHONY: run run-dev test verify start ticket clean .PHONY: verify start ticket publish install clean help
# Puerto por defecto
PORT?=8000
run:
@echo "Arrancando ARNES API en http://localhost:$(PORT)/ui/login.html"
@echo "Credenciales: alice@example.com / SecurePass123!"
python3 -m uvicorn src.main:app --host 0.0.0.0 --port $(PORT)
run-dev:
@echo "Arrancando en modo desarrollo (auto-reload)..."
python3 -m uvicorn src.main:app --reload --port $(PORT)
test:
python3 -m unittest discover -s tests
verify: verify:
./scripts/verify.sh ./scripts/verify.sh
@@ -24,22 +9,24 @@ start:
ticket: ticket:
python3 scripts/new_ticket.py python3 scripts/new_ticket.py
publish:
@test -n "$(FEATURE_ID)" || (echo "Use: make publish FEATURE_ID=F-001" && exit 1)
python3 scripts/publish_ticket.py --feature-id $(FEATURE_ID)
install:
@test -n "$(TARGET)" || (echo "Use: make install TARGET=/path/to/project-repo" && exit 1)
./scripts/install_into_repo.sh $(TARGET)
clean: clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true
# Help
help: help:
@echo "ARNES UI API - Comandos disponibles:" @echo "ARNES template - commands:"
@echo "" @echo ""
@echo " make run - Arrancar servidor (puerto 8000)" @echo " make verify - Verify harness core"
@echo " make run PORT=8080 - Arrancar en puerto específico" @echo " make start - First-run wizard"
@echo " make run-dev - Arrancar con auto-reload" @echo " make ticket - Create ticket (EN caveman)"
@echo " make test - Ejecutar tests unitarios" @echo " make publish FEATURE_ID=.. - Commit and push one ticket"
@echo " make verify - Verificar harness" @echo " make install TARGET=.. - Install ARNES into external repo"
@echo " make start - Wizard de inicio de proyecto" @echo " make clean - Clean cache files"
@echo " make ticket - Crear ticket (EN caveman)"
@echo " make clean - Limpiar cache"
@echo ""
@echo "URLs:"
@echo " http://localhost:8000/ui/login.html"

View File

@@ -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
```

View File

@@ -1,6 +1,9 @@
# ARNES Framework (agnóstico) — Diseño v0.1 # ARNES Framework (agnóstico) — Diseño v0.1
Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad. Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad.
Convención recomendada: el código real del proyecto vive dentro de `project/`.
Cada proyecto real debe vivir en **su propio repo git**, distinto del repo fuente de ARNES.
Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores. Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores.
--- ---
@@ -67,6 +70,7 @@ Permitir que agentes implementen features de forma autónoma **sin perder contro
7. `qa_gate` (qa) ✅ 7. `qa_gate` (qa) ✅
8. `documentation_gate` (documenter) ✅ 8. `documentation_gate` (documenter) ✅
9. `close` (leader) 9. `close` (leader)
10. `publish` (leader) ✅
**Regla:** no hay `done` si cualquier gate falla. **Regla:** no hay `done` si cualquier gate falla.
@@ -86,6 +90,7 @@ Cada agente escribe artefactos en disco:
- `work/artifacts/<feature>/security.json` - `work/artifacts/<feature>/security.json`
- `work/artifacts/<feature>/qa.json` - `work/artifacts/<feature>/qa.json`
- `work/artifacts/<feature>/leader-close.json` - `work/artifacts/<feature>/leader-close.json`
- `work/artifacts/<feature>/publish.json`
Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`. Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
@@ -104,29 +109,38 @@ Respuesta de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
```text ```text
. .
├── project/ # código real del proyecto
│ └── README.md
├── README.md ├── README.md
├── AGENTS.md
├── CHECKPOINTS.md
├── harness/ ├── harness/
│ ├── agents.matrix.yml │ ├── agents.matrix.yml
│ ├── workflow.stages.yml │ ├── workflow.stages.yml
│ ├── models.profiles.yml
│ ├── policies/ │ ├── policies/
│ │ ├── security.md
│ │ ├── quality.md
│ │ └── governance.md
│ └── contracts/ │ └── contracts/
│ ├── handoff.md
│ └── evidence.schema.json
├── spec/ ├── spec/
│ ├── product.md │ ├── product.md
│ ├── tech.md │ ├── tech.md
── acceptance.md ── acceptance.md
│ ├── sdd/
│ └── bdd/
├── backlog/ ├── backlog/
│ └── features.json │ └── features.json
├── work/ ├── work/
│ ├── current.md │ ├── current.md
│ ├── history.md │ ├── history.md
│ ├── runtime-status.json
│ └── artifacts/ │ └── artifacts/
── scripts/ ── scripts/
── verify.sh ── start.sh
│ ├── new_ticket.py
│ ├── agent_status.py
│ └── verify.sh
├── defaults/
│ └── flask-skeleton/
└── platforms/
``` ```
--- ---
@@ -186,16 +200,44 @@ El núcleo no cambia; solo el adaptador.
## Inicio rápido ## Inicio rápido
- Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo`
- Ejecuta wizard: `./scripts/start.sh` - Ejecuta wizard: `./scripts/start.sh`
- Crear ticket: `python3 scripts/new_ticket.py` - Crear ticket: `python3 scripts/new_ticket.py`
- Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
- Guía breve: `HOWTO.md` - Guía breve: `HOWTO.md`
- Starter pack: `starter-pack/README.md` - Starter pack: `starter-pack/README.md`
- Adaptación del template: `TEMPLATE.md` - Adaptación del template: `TEMPLATE.md`
- Layout del repo: `docs/repository-layout.md`
- Referencia de scripts: `docs/scripts-reference.md`
- Manual Skeleton (uso + mejoras): `docs/skeleton-manual.md` - Manual Skeleton (uso + mejoras): `docs/skeleton-manual.md`
## Tipos de tarea / ticket
`python3 scripts/new_ticket.py` soporta estos tipos:
- `feature`: nueva capacidad
- `fix`: corrección de comportamiento roto
- `bug`: incidencia reportada o defecto claro
- `chore`: trabajo interno, refactor, setup, mantenimiento
Además guarda campos estructurados:
- `problem`
- `goal`
- `scope_in`
- `scope_out`
- `priority`
- `risk`
- `acceptance`
Convención recomendada:
- usar `feature` para trabajo nuevo visible
- usar `fix` o `bug` para reparación
- usar `chore` para cambios internos sin valor funcional directo
## Próximos pasos sugeridos ## Próximos pasos sugeridos
1. Definir el backlog inicial del proyecto real. 1. Instalar/copiar ARNES en un repo de proyecto real distinto del repo fuente.
2. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`). 2. Definir el backlog inicial del proyecto real.
3. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`. 3. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`).
4. Empezar la primera feature `pending` con pipeline completo. 4. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`.
5. Empezar la primera feature `pending` con pipeline completo y terminar con commit+push del ticket.

View File

@@ -1,6 +1,7 @@
# TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto # TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto
## 1) Clonar y renombrar contexto ## 1) Clonar y renombrar contexto
- Pon el código real dentro de `project/` (o elige otra ruta en `./scripts/start.sh`).
- Ajusta `backlog/features.json` (`project`, `description`). - Ajusta `backlog/features.json` (`project`, `description`).
- Crea primeras features reales en `features[]`. - Crea primeras features reales en `features[]`.
@@ -8,16 +9,19 @@
- Opcional: crea `AGENTS.local.md` con reglas del dominio. - Opcional: crea `AGENTS.local.md` con reglas del dominio.
- Opcional: crea `scripts/verify.local.sh` con checks propios del stack. - Opcional: crea `scripts/verify.local.sh` con checks propios del stack.
- Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`). - Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`).
- Usa tipos de ticket consistentes: `feature`, `fix`, `bug`, `chore`.
- Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`. - Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`.
## 3) Flujo estándar ## 3) Flujo estándar
0. Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo`
1. `./scripts/start.sh` (primer uso) 1. `./scripts/start.sh` (primer uso)
2. `python3 scripts/new_ticket.py` (leader/triager) 2. `python3 scripts/new_ticket.py` (leader/triager)
3. `python3 scripts/agent_status.py show` 3. `python3 scripts/agent_status.py show`
4. Seleccionar 1 feature `pending` y pasarla a `in_progress` 4. Seleccionar 1 feature `pending` y pasarla a `in_progress`
5. Implementar con artefactos en `work/artifacts/<feature_id>/` 5. Implementar con artefactos en `work/artifacts/<feature_id>/`
6. Cerrar solo con gates `review/security/qa` + `documenter` aprobados 6. Cerrar con gates `review/security/qa` + `documenter` aprobados
7. `python3 scripts/agent_status.py reset` 7. Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001`
8. `python3 scripts/agent_status.py reset`
## 4) Contrato de cierre ## 4) Contrato de cierre
- `status=done` exige: - `status=done` exige:
@@ -25,6 +29,7 @@
- `security.json` APPROVED - `security.json` APPROVED
- `qa.json` APPROVED - `qa.json` APPROVED
- `leader-close.json` APPROVED - `leader-close.json` APPROVED
- `publish.json` PUBLISHED
- `./scripts/verify.sh` OK - `./scripts/verify.sh` OK
## 5) Principio de template ## 5) Principio de template

View File

@@ -11,15 +11,32 @@
"in_progress", "in_progress",
"blocked", "blocked",
"done" "done"
],
"valid_types": [
"feature",
"fix",
"bug",
"chore"
] ]
}, },
"template_feature_schema": { "template_feature_schema": {
"id": "F-001", "id": "F-001",
"title": "Título de la feature", "type": "feature",
"description": "Descripción funcional", "title": "Short ticket title",
"problem": "Need change",
"goal": "Make flow better",
"scope_in": [
"Core flow"
],
"scope_out": [
"No redesign"
],
"priority": "med",
"risk": "low",
"description": "Problem: ... Goal: ... Scope IN: ... Scope OUT: ... Type: ... Priority: ... Risk: ...",
"acceptance": [ "acceptance": [
"Criterio 1", "Flow works end to end",
"Criterio 2" "No break old behavior"
], ],
"status": "pending", "status": "pending",
"created_at": "YYYY-MM-DD", "created_at": "YYYY-MM-DD",

38
docs/repository-layout.md Normal file
View 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
View 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
View 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/`.

View File

@@ -1,10 +1,8 @@
[behave] [behave]
paths = features/ paths = features/
format = pretty format = pretty
tags = @F-001
# Para ejecutar solo smoke tests: # Examples:
# behave features/
# behave features/ --tags @smoke # behave features/ --tags @smoke
# behave features/ --tags ~@slow
# Para excluir tests lentos:
# behave features/ --tags ~@slow

0
features/steps/.gitkeep Normal file
View File

View 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"

View File

@@ -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"

View File

@@ -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

View File

@@ -4,18 +4,19 @@ roles:
leader: leader:
emoji: "🧭" emoji: "🧭"
can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"] can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"]
cannot_edit: ["src/", "tests/"] cannot_edit: ["project/", "tests/"]
responsibilities: responsibilities:
- plan - plan
- orchestrate - orchestrate
- enforce_gates - enforce_gates
- publish_ticket_changes
- close_feature - close_feature
- issue_orders_in_english_caveman - issue_orders_in_english_caveman
triager: triager:
emoji: "🧩" emoji: "🧩"
can_edit: ["backlog/", "work/artifacts/", "spec/"] can_edit: ["backlog/", "work/artifacts/", "spec/"]
cannot_edit: ["src/", "tests/", "backlog/features.json:status=done"] cannot_edit: ["project/", "tests/", "backlog/features.json:status=done"]
responsibilities: responsibilities:
- normalize_requests - normalize_requests
- create_tickets_in_english_caveman - create_tickets_in_english_caveman
@@ -24,20 +25,21 @@ roles:
architect: architect:
emoji: "🏗️" emoji: "🏗️"
can_edit: ["spec/", "harness/contracts/", "docs/"] can_edit: ["spec/", "harness/contracts/", "docs/"]
cannot_edit: ["src/", "tests/", "backlog/features.json:status"] cannot_edit: ["project/", "tests/", "backlog/features.json:status"]
responsibilities: responsibilities:
- design - design
- update_contracts - update_contracts
implementer: implementer:
emoji: "🛠️" emoji: "🛠️"
can_edit: ["src/", "tests/", "work/artifacts/"] can_edit: ["project/", "tests/", "work/artifacts/"]
cannot_edit: cannot_edit:
- "backlog/features.json:done" - "backlog/features.json:done"
- "work/history.md" - "work/history.md"
- "work/artifacts/*/reviewer.json" - "work/artifacts/*/reviewer.json"
- "work/artifacts/*/security.json" - "work/artifacts/*/security.json"
- "work/artifacts/*/qa.json" - "work/artifacts/*/qa.json"
- "work/artifacts/*/publish.json"
- "work/artifacts/*/leader-close.json" - "work/artifacts/*/leader-close.json"
responsibilities: responsibilities:
- implement_feature - implement_feature
@@ -47,7 +49,7 @@ roles:
reviewer: reviewer:
emoji: "🔍" emoji: "🔍"
can_edit: ["work/artifacts/"] can_edit: ["work/artifacts/"]
cannot_edit: ["src/", "tests/", "backlog/"] cannot_edit: ["project/", "tests/", "backlog/"]
responsibilities: responsibilities:
- technical_review - technical_review
- emit_reviewer_verdict - emit_reviewer_verdict
@@ -55,7 +57,7 @@ roles:
security: security:
emoji: "🔒" emoji: "🔒"
can_edit: ["work/artifacts/"] can_edit: ["work/artifacts/"]
cannot_edit: ["src/", "tests/", "backlog/"] cannot_edit: ["project/", "tests/", "backlog/"]
responsibilities: responsibilities:
- sast - sast
- dependency_review - dependency_review
@@ -65,7 +67,7 @@ roles:
qa: qa:
emoji: "🧪" emoji: "🧪"
can_edit: ["work/artifacts/"] can_edit: ["work/artifacts/"]
cannot_edit: ["src/", "tests/", "backlog/"] cannot_edit: ["project/", "tests/", "backlog/"]
responsibilities: responsibilities:
- acceptance_traceability - acceptance_traceability
- integration_e2e_checks - integration_e2e_checks
@@ -75,7 +77,7 @@ roles:
documenter: documenter:
emoji: "📚" emoji: "📚"
can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"] can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"]
cannot_edit: ["src/", "tests/", "backlog/features.json:status"] cannot_edit: ["project/", "tests/", "backlog/features.json:status"]
responsibilities: responsibilities:
- document_feature_changes - document_feature_changes
- update_user_docs - update_user_docs

View File

@@ -63,9 +63,16 @@ stages:
- work/artifacts/<feature_id>/leader-close.json - work/artifacts/<feature_id>/leader-close.json
- work/history.md - work/history.md
- name: publish
owner: leader
required: true
output:
- work/artifacts/<feature_id>/publish.json
close_requirements: close_requirements:
- reviewer.json.verdict == "APPROVED" - reviewer.json.verdict == "APPROVED"
- security.json.verdict == "APPROVED" - security.json.verdict == "APPROVED"
- qa.json.verdict == "APPROVED" - qa.json.verdict == "APPROVED"
- documenter.md exists - documenter.md exists
- publish.json.verdict == "PUBLISHED"
- scripts/verify.sh exit_code == 0 - scripts/verify.sh exit_code == 0

11
project/README.md Normal file
View 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.

View File

@@ -1,6 +0,0 @@
{
"extends": ["pytest:."],
"testpaths": ["tests"],
"pythonpath": ["."],
"addopts": "-v"
}

View File

@@ -1,7 +1,2 @@
fastapi>=0.100.0 # Template core has no hard runtime deps.
uvicorn>=0.23.0 # Add project-specific dependencies after running ./scripts/start.sh
pydantic>=2.0.0
pytest>=7.0.0
httpx>=0.24.0
PyJWT>=2.8.0
bcrypt>=4.0.0

View File

@@ -8,7 +8,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
STATUS_PATH = ROOT / 'work' / 'runtime-status.json' STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml' MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml'
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts' ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'}
DEFAULT_EMOJIS = { DEFAULT_EMOJIS = {
'leader': '🧭', 'leader': '🧭',
@@ -26,6 +28,7 @@ GATE_FILES = {
'security': 'security.json', 'security': 'security.json',
'qa': 'qa.json', 'qa': 'qa.json',
'documenter': 'documenter.md', 'documenter': 'documenter.md',
'publish': 'publish.json',
'leader': 'leader-close.json', 'leader': 'leader-close.json',
} }
@@ -60,6 +63,28 @@ def load_role_emojis():
return emojis return emojis
def load_roles():
roles = []
if not MATRIX_PATH.exists():
return roles
for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines():
match_role = re.match(r'^ ([a-z_]+):\s*$', line)
if match_role:
roles.append(match_role.group(1))
return roles
def load_stage_names():
stages = []
if not WORKFLOW_PATH.exists():
return stages
for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines():
match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line)
if match_stage:
stages.append(match_stage.group(1))
return stages
def default_status(): def default_status():
return { return {
'feature_id': None, 'feature_id': None,
@@ -99,7 +124,8 @@ def gate_status(feature_id):
continue continue
try: try:
payload = json.loads(path.read_text(encoding='utf-8')) payload = json.loads(path.read_text(encoding='utf-8'))
gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present' wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED'
gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present'
except Exception: except Exception:
gates[gate] = 'invalid' gates[gate] = 'invalid'
return gates return gates
@@ -115,10 +141,25 @@ def render_gate(gate, state, emojis):
label = { label = {
'leader': 'close', 'leader': 'close',
'documenter': 'docs', 'documenter': 'docs',
'publish': 'publish',
}.get(gate, gate) }.get(gate, gate)
return f"{icon} {emojis.get(gate, '')} {label}: {state.upper()}" return f"{icon} {emojis.get(gate, '')} {label}: {state.upper()}"
def validate_runtime_args(args):
roles = set(load_roles()) or set(DEFAULT_EMOJIS)
stages = set(load_stage_names()) | {'idle'}
if args.agent is not None and args.agent not in roles:
raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}")
if args.next_agent is not None and args.next_agent not in roles:
raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}")
if args.stage is not None and args.stage not in stages:
raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}")
if args.state is not None and args.state not in VALID_RUNTIME_STATES:
raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}")
def show_status(): def show_status():
status = load_status() status = load_status()
emojis = load_role_emojis() emojis = load_role_emojis()
@@ -141,7 +182,7 @@ def show_status():
print() print()
print('Gates') print('Gates')
if gates: if gates:
for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']: for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']:
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}") print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
else: else:
print(' — Sin feature activa —') print(' — Sin feature activa —')
@@ -162,6 +203,7 @@ def show_status():
def set_status(args): def set_status(args):
validate_runtime_args(args)
status = load_status() status = load_status()
if args.feature_id is not None: if args.feature_id is not None:
status['feature_id'] = args.feature_id or None status['feature_id'] = args.feature_id or None

66
scripts/install_into_repo.sh Executable file
View 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"

View File

@@ -5,6 +5,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
BACKLOG = ROOT / 'backlog' / 'features.json' BACKLOG = ROOT / 'backlog' / 'features.json'
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
LEVEL_CHOICES = ('low', 'med', 'high')
def ask(prompt, default=''): def ask(prompt, default=''):
@@ -12,10 +14,23 @@ def ask(prompt, default=''):
return value if value else default return value if value else default
def ask_choice(prompt, choices, default):
while True:
value = ask(prompt, default).lower()
if value in choices:
return value
print(f"Invalid value. Use one of: {', '.join(choices)}")
def ask_list(prompt, default_csv=''):
raw = ask(prompt, default_csv)
return [item.strip() for item in raw.split(',') if item.strip()]
def next_id(features): def next_id(features):
nums = [] nums = []
for f in features: for feature in features:
fid = str(f.get('id', '')) fid = str(feature.get('id', ''))
if fid.startswith('F-') and fid[2:].isdigit(): if fid.startswith('F-') and fid[2:].isdigit():
nums.append(int(fid[2:])) nums.append(int(fid[2:]))
return f"F-{(max(nums) + 1) if nums else 1:03d}" return f"F-{(max(nums) + 1) if nums else 1:03d}"
@@ -26,14 +41,14 @@ def main():
features = data.get('features', []) features = data.get('features', [])
print('Create ticket (English caveman style).') print('Create ticket (English caveman style).')
ttype = ask('Type (feature/fix/bug/chore)', 'feature') ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
title = ask('Title (short EN)', f'{ttype.capitalize()} TODO') title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
problem = ask('Problem (short EN)', 'Need change') problem = ask('Problem (short EN)', 'Need change')
goal = ask('Goal (short EN)', 'Make flow better') goal = ask('Goal (short EN)', 'Make flow better')
scope_in = ask('Scope IN (comma list EN)', 'Core flow') scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
scope_out = ask('Scope OUT (comma list EN)', 'No redesign') scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
risk = ask('Risk (low/med/high)', 'low') risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
priority = ask('Priority (low/med/high)', 'med') priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
print('Acceptance bullets (EN caveman). Empty line to end.') print('Acceptance bullets (EN caveman). Empty line to end.')
acceptance = [] acceptance = []
@@ -47,29 +62,38 @@ def main():
acceptance = [ acceptance = [
'Flow works end to end', 'Flow works end to end',
'No break old behavior', 'No break old behavior',
'verify.sh is green' 'verify.sh is green',
] ]
fid = next_id(features) fid = next_id(features)
desc = ( desc = (
f"Problem: {problem}. " f"Problem: {problem}. "
f"Goal: {goal}. " f"Goal: {goal}. "
f"Scope IN: {scope_in}. " f"Scope IN: {', '.join(scope_in) or 'none'}. "
f"Scope OUT: {scope_out}. " f"Scope OUT: {', '.join(scope_out) or 'none'}. "
f"Type: {ttype}. Priority: {priority}. Risk: {risk}." f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
) )
features.append({ features.append({
'id': fid, 'id': fid,
'type': ticket_type,
'title': title, 'title': title,
'problem': problem,
'goal': goal,
'scope_in': scope_in,
'scope_out': scope_out,
'priority': priority,
'risk': risk,
'description': desc, 'description': desc,
'acceptance': acceptance, 'acceptance': acceptance,
'status': 'pending', 'status': 'pending',
'created_at': str(date.today()), 'created_at': str(date.today()),
'gates': {'review': False, 'security': False, 'qa': False} 'gates': {'review': False, 'security': False, 'qa': False},
}) })
data['features'] = features data['features'] = features
rules = data.setdefault('rules', {})
rules.setdefault('valid_types', list(TYPE_CHOICES))
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
print(f'Created {fid}: {title}') print(f'Created {fid}: {title}')

133
scripts/publish_ticket.py Executable file
View 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()

View File

@@ -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

View File

@@ -16,12 +16,16 @@ ask() {
} }
echo "=== ARNES start wizard ===" echo "=== ARNES start wizard ==="
echo "Mode: use this template in a new repo or copy core ARNES into an existing repo."
echo "Mode: clone arnes-fork, put your app folder inside, run this wizard." if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "No git repo detected. Initializing local git repository..."
git init >/dev/null
fi
PROJECT_NAME="$(ask 'Project name' 'my-project')" PROJECT_NAME="$(ask 'Project name' 'my-project')"
PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')" PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')"
APP_DIR="$(ask 'App directory (relative)' 'app')" APP_DIR="$(ask 'App directory (relative)' 'project')"
STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')" STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')"
if [ "$STACK_CHOICE" = "2" ]; then if [ "$STACK_CHOICE" = "2" ]; then
@@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')"
ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')" ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')"
mkdir -p "$APP_DIR" mkdir -p "$APP_DIR"
[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" <<EOF
# Project code
This directory holds the real project code.
Configured by ARNES start wizard.
EOF
if [ "$BACKEND" = "python/flask" ]; then
mkdir -p "$APP_DIR/templates" "$APP_DIR/static/js" "$APP_DIR/static/css" "$APP_DIR/static/images"
fi
if [ "$CSSFW" = "skeleton" ]; then if [ "$CSSFW" = "skeleton" ]; then
mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images" mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images"
cp -n defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css" || true [ -f "$APP_DIR/static/css/normalize.css" ] || cp defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css"
cp -n defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css" || true [ -f "$APP_DIR/static/css/skeleton.css" ] || cp defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css"
cp -n defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png" || true [ -f "$APP_DIR/static/images/favicon.png" ] || cp defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png"
fi fi
cat > harness/project.config.json <<JSON cat > harness/project.config.json <<JSON
@@ -82,7 +96,7 @@ APP_DIR=$(python3 - <<'PY'
import json import json
from pathlib import Path from pathlib import Path
cfg=json.loads(Path('harness/project.config.json').read_text()) cfg=json.loads(Path('harness/project.config.json').read_text())
print(cfg.get('app_dir','app')) print(cfg.get('app_dir','project'))
PY PY
) )
TEST_CMD=$(python3 - <<'PY' TEST_CMD=$(python3 - <<'PY'
@@ -126,25 +140,34 @@ import json
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
b=Path('backlog/features.json') b = Path('backlog/features.json')
data=json.loads(b.read_text(encoding='utf-8')) data = json.loads(b.read_text(encoding='utf-8'))
data['project']='$PROJECT_NAME' data['project'] = '$PROJECT_NAME'
data['description']='$PROJECT_DESC' data['description'] = '$PROJECT_DESC'
features=data.get('features',[]) rules = data.setdefault('rules', {})
rules.setdefault('valid_types', ['feature', 'fix', 'bug', 'chore'])
features = data.get('features', [])
if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features: if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features:
features.append({ features.append({
'id':'F-001', 'id': 'F-001',
'title':'Bootstrap ARNES on project', 'type': 'chore',
'description':'Setup ARNES pipeline and run first complete feature cycle.', 'title': 'Bootstrap ARNES on project',
'acceptance':['verify.sh is green','runtime status works','first feature closes with gates'], 'problem': 'Need base workflow and control',
'status':'pending', 'goal': 'Make ARNES ready on this repo',
'created_at':str(date.today()), 'scope_in': ['Harness setup', 'Runtime status', 'First verify cycle'],
'gates':{'review':False,'security':False,'qa':False} 'scope_out': ['Business feature work', 'Product redesign'],
'priority': 'med',
'risk': 'low',
'description': 'Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.',
'acceptance': ['verify.sh is green', 'runtime status works', 'first feature closes with gates'],
'status': 'pending',
'created_at': str(date.today()),
'gates': {'review': False, 'security': False, 'qa': False}
}) })
data['features']=features data['features'] = features
b.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n',encoding='utf-8') b.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
PY PY
cat > work/current.md <<EOF cat > work/current.md <<EOF
@@ -169,5 +192,6 @@ echo "Done. Project configured."
echo "- Config: harness/project.config.json" echo "- Config: harness/project.config.json"
echo "- Local checks: scripts/verify.local.sh" echo "- Local checks: scripts/verify.local.sh"
echo "- Ticket tool: python3 scripts/new_ticket.py" echo "- Ticket tool: python3 scripts/new_ticket.py"
echo "- Publish tool: python3 scripts/publish_ticket.py --feature-id F-001"
echo "- Verify: ./scripts/verify.sh" echo "- Verify: ./scripts/verify.sh"
echo "- Runtime: python3 scripts/agent_status.py show" echo "- Runtime: python3 scripts/agent_status.py show"

View File

@@ -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)

View File

@@ -18,6 +18,11 @@ echo "── 1) Verificando estructura base ────────────
required=( required=(
"AGENTS.md" "AGENTS.md"
"CHECKPOINTS.md" "CHECKPOINTS.md"
"README.md"
"HOWTO.md"
"TEMPLATE.md"
"docs/repository-layout.md"
"docs/scripts-reference.md"
"harness/agents.matrix.yml" "harness/agents.matrix.yml"
"harness/workflow.stages.yml" "harness/workflow.stages.yml"
"harness/policies/governance.md" "harness/policies/governance.md"
@@ -31,14 +36,24 @@ required=(
"spec/product.md" "spec/product.md"
"spec/tech.md" "spec/tech.md"
"spec/acceptance.md" "spec/acceptance.md"
"spec/bdd/README.md"
"spec/bdd/features/README.md"
"spec/sdd/README.md"
"spec/sdd/components/README.md"
"spec/sdd/decisions/README.md"
"features/README.md"
"project/README.md"
"backlog/features.json" "backlog/features.json"
"work/current.md" "work/current.md"
"work/history.md" "work/history.md"
"work/runtime-status.json" "work/runtime-status.json"
"scripts/agent_status.py" "scripts/agent_status.py"
"scripts/new_ticket.py" "scripts/new_ticket.py"
"scripts/publish_ticket.py"
"scripts/install_into_repo.sh"
"scripts/start.sh" "scripts/start.sh"
"platforms/pi/README.md" "platforms/pi/README.md"
"platforms/opencode/README.md"
) )
for f in "${required[@]}"; do for f in "${required[@]}"; do
@@ -50,6 +65,20 @@ for f in "${required[@]}"; do
fi fi
done done
echo ""
echo "── 1.5) Validando git repo ───────────────────────────"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ok "Git repo detectado"
if git remote | grep -q .; then
ok "Git remote configurado"
else
warn "Sin git remote configurado (publish requerirá remote)"
fi
else
fail "Este proyecto debe vivir dentro de un git repo"
EXIT_CODE=1
fi
echo "" echo ""
echo "── 2) Validando backlog + gates ───────────────────────" echo "── 2) Validando backlog + gates ───────────────────────"
python3 - <<'PY' python3 - <<'PY'
@@ -59,6 +88,7 @@ import sys
root = pathlib.Path('.') root = pathlib.Path('.')
path = root / 'backlog' / 'features.json' path = root / 'backlog' / 'features.json'
level_choices = {'low', 'med', 'high'}
try: try:
data = json.loads(path.read_text(encoding='utf-8')) data = json.loads(path.read_text(encoding='utf-8'))
@@ -66,7 +96,9 @@ except Exception as e:
print(f"[FAIL] backlog/features.json inválido: {e}") print(f"[FAIL] backlog/features.json inválido: {e}")
sys.exit(1) sys.exit(1)
valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"])) rules = data.get('rules', {})
valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"]))
valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"]))
features = data.get('features', []) features = data.get('features', [])
if not isinstance(features, list): if not isinstance(features, list):
print('[FAIL] features debe ser una lista') print('[FAIL] features debe ser una lista')
@@ -85,25 +117,65 @@ if len(in_progress) > 1:
for f in features: for f in features:
fid = str(f.get('id', '')).strip() fid = str(f.get('id', '')).strip()
status = f.get('status') status = f.get('status')
if status not in valid: title = str(f.get('title', '')).strip()
acceptance = f.get('acceptance')
gates = f.get('gates', {})
if not fid:
print('[FAIL] Hay una feature sin id')
sys.exit(1)
if not title:
print(f"[FAIL] Feature {fid} sin title")
sys.exit(1)
if status not in valid_status:
print(f"[FAIL] Estado inválido en feature {fid}: {status}") print(f"[FAIL] Estado inválido en feature {fid}: {status}")
sys.exit(1) sys.exit(1)
if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance):
print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía")
sys.exit(1)
ticket_type = f.get('type')
if ticket_type is not None and ticket_type not in valid_types:
print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}")
sys.exit(1)
for field in ('priority', 'risk'):
value = f.get(field)
if value is not None and value not in level_choices:
print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}")
sys.exit(1)
for field in ('scope_in', 'scope_out'):
value = f.get(field)
if value is not None:
if not isinstance(value, list) or any(not str(item).strip() for item in value):
print(f"[FAIL] Feature {fid} tiene {field} inválido")
sys.exit(1)
if gates:
for gate_name in ('review', 'security', 'qa'):
gate_value = gates.get(gate_name)
if not isinstance(gate_value, bool):
print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido")
sys.exit(1)
if status == 'done': if status == 'done':
d = root / 'work' / 'artifacts' / fid d = root / 'work' / 'artifacts' / fid
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md'] req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.json']
missing = [name for name in req if not (d / name).is_file()] missing = [name for name in req if not (d / name).is_file()]
if missing: if missing:
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}") print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
sys.exit(1) sys.exit(1)
expected = { expected = {
'reviewer.json': 'reviewer', 'reviewer.json': ('reviewer', 'APPROVED'),
'security.json': 'security', 'security.json': ('security', 'APPROVED'),
'qa.json': 'qa', 'qa.json': ('qa', 'APPROVED'),
'leader-close.json': 'leader', 'leader-close.json': ('leader', 'APPROVED'),
'publish.json': ('leader', 'PUBLISHED'),
} }
for filename, agent in expected.items(): for filename, rule in expected.items():
agent, verdict = rule
try: try:
obj = json.loads((d / filename).read_text(encoding='utf-8')) obj = json.loads((d / filename).read_text(encoding='utf-8'))
except Exception as e: except Exception as e:
@@ -113,8 +185,11 @@ for f in features:
if obj.get('agent') != agent: if obj.get('agent') != agent:
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'") print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
sys.exit(1) sys.exit(1)
if obj.get('verdict') != 'APPROVED': if obj.get('verdict') != verdict:
print(f"[FAIL] {fid}/{filename} no está APPROVED") print(f"[FAIL] {fid}/{filename} no está {verdict}")
sys.exit(1)
if filename == 'publish.json' and obj.get('pushed') is not True:
print(f"[FAIL] {fid}/{filename} debe tener pushed=true")
sys.exit(1) sys.exit(1)
print(f"[OK] backlog válido ({len(features)} features)") print(f"[OK] backlog válido ({len(features)} features)")

View File

@@ -10,13 +10,16 @@
## Overview ## Overview
Este directorio contiene especificaciones BDD en formato Gherkin. Este directorio contiene las especificaciones BDD fuente en formato Gherkin.
Los archivos `.feature` sirven como especificación ejecutable.
Separación recomendada:
- `spec/bdd/features/` = source-of-truth de escenarios
- `features/` = assets ejecutables del runner (steps, config)
### naming conventions ### naming conventions
``` ```text
features/ spec/bdd/features/
├── <domain>/ ├── <domain>/
│ ├── <feature-name>.feature │ ├── <feature-name>.feature
│ └── <feature-name>.feature │ └── <feature-name>.feature

View File

View File

@@ -1,58 +1,12 @@
# Features BDD # BDD feature files
Este directorio contiene los archivos `.feature` organizados por dominio. Put Gherkin `.feature` files here.
## Estructura Example:
- `spec/bdd/features/checkout/purchase.feature`
- `spec/bdd/features/common/error-handling.feature`
``` Use tags like:
features/ - `@F-001`
├── auth/ - `@smoke`
│ ├── login.feature - `@regression`
│ └── registration.feature
├── dashboard/
│ └── dashboard.feature
├── common/
│ ├── navigation.feature
│ └── error-handling.feature
└── README.md
```
## Tags comunes
Usar estos tags en todos los features:
| Tag | Descripción |
|-----|-------------|
| `@F-XXX` | Link a feature ID del backlog |
| `@smoke` | Test crítico |
| `@regression` | Regresión |
## Example
```gherkin
@F-001 @auth @smoke
Feature: Inicio de sesión
Como usuario registrado
Quiero iniciar sesión con mis credenciales
Para acceder a mi cuenta personal
@positive
Scenario: Login exitoso con credenciales válidas
Given un usuario con email "user@example.com" y password "Password123"
And el usuario no tiene sesión activa
When el usuario ingresa email "user@example.com"
And ingresa password "Password123"
And presiona el botón "Iniciar sesión"
Then el sistema redirige al dashboard
And muestra mensaje de bienvenida
@negative
Scenario: Login fallido con password incorrecto
Given un usuario con email "user@example.com" y password "Password123"
When el usuario ingresa email "user@example.com"
And ingresa password "WrongPassword"
And presiona el botón "Iniciar sesión"
Then el sistema muestra mensaje de error "Credenciales inválidas"
And permanece en la página de login
```

View File

@@ -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

View File

@@ -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

View File

@@ -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"
```

View File

@@ -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

View File

@@ -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"

View File

@@ -1,4 +1,4 @@
# SDD/BBD Guide — System Design Document & Behavior Driven Development # SDD/BDD Guide — System Design Document & Behavior Driven Development
Guía para crear y mantener SDD (System Design Document) y BDD (Behavior Driven Development) specs dentro del framework ARNES. Guía para crear y mantener SDD (System Design Document) y BDD (Behavior Driven Development) specs dentro del framework ARNES.
@@ -25,10 +25,13 @@ spec/
│ ├── architecture.md │ ├── architecture.md
│ ├── components/ │ ├── components/
│ └── decisions/ │ └── decisions/
└── bdd/ # Behavior Driven Development └── bdd/ # Behavior Driven Development source-of-truth
├── README.md ├── README.md
── features/ ── features/
└── step_definitions/
features/ # optional executable BDD runner assets
├── behave.ini
└── steps/
``` ```
--- ---
@@ -152,6 +155,11 @@ spec/bdd/features/
│ └── purchase.feature │ └── purchase.feature
└── common/ └── common/
└── error-handling.feature └── error-handling.feature
features/
├── behave.ini
└── steps/
└── login_steps.py
``` ```
### Tags para trazabilidad ### Tags para trazabilidad
@@ -224,8 +232,10 @@ Tags disponibles:
```bash ```bash
# Estructura # Estructura
spec/bdd/features/
└── login.feature
features/ features/
├── login.feature
└── steps/ └── steps/
└── login_steps.py └── login_steps.py
@@ -237,8 +247,10 @@ behave features/
```bash ```bash
# Estructura # Estructura
spec/bdd/features/
└── login.feature
features/ features/
├── login.feature
└── step_definitions/ └── step_definitions/
└── login_steps.js └── login_steps.js

View File

View 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: ...

View File

@@ -0,0 +1,8 @@
# SDD components
Put one markdown file per technical component.
Example:
- `api-gateway.md`
- `order-service.md`
- `cart-repository.md`

View File

@@ -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

View File

@@ -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`

View File

@@ -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
```

View File

@@ -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

View File

@@ -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`

View File

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
# SDD decisions
Put ADRs (Architecture Decision Records) here.
Example:
- `001-use-flask.md`
- `002-use-mariadb.md`

View File

@@ -1 +0,0 @@
"""Package init."""

Binary file not shown.

View File

@@ -1 +0,0 @@
"""API init."""

View File

@@ -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)
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -1 +0,0 @@
"""Models init."""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1 +0,0 @@
"""Services init."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -4,15 +4,15 @@ Este pack sirve para arrancar ARNES en 2 escenarios:
## A) Proyecto nuevo (greenfield) ## A) Proyecto nuevo (greenfield)
1. Crea repo vacío. 1. Crea repo vacío.
2. Copia el template ARNES. 2. Instala el core ARNES desde otro repo fuente (`scripts/install_into_repo.sh`).
3. Ajusta `backlog/features.json` (`project`, `description`). 3. Ajusta `backlog/features.json` (`project`, `description`).
4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature. 4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature (`type=chore`).
5. Ejecuta: 5. Ejecuta:
- `./scripts/verify.sh` - `./scripts/verify.sh`
- `python3 scripts/agent_status.py show` - `python3 scripts/agent_status.py show`
## B) Proyecto ya empezado (brownfield) ## B) Proyecto ya empezado (brownfield)
1. Copia **solo** carpetas core ARNES: `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. 1. Instala **solo** el core ARNES dentro del repo existente.
2. Mantén tu código actual intacto. 2. Mantén tu código actual intacto.
3. Añade checks del dominio en `scripts/verify.local.sh`. 3. Añade checks del dominio en `scripts/verify.local.sh`.
4. Define features reales del proyecto en `backlog/features.json`. 4. Define features reales del proyecto en `backlog/features.json`.
@@ -22,5 +22,7 @@ Este pack sirve para arrancar ARNES en 2 escenarios:
## Reglas mínimas ## Reglas mínimas
- 1 sola feature en `in_progress`. - 1 sola feature en `in_progress`.
- Tipos válidos: `feature`, `fix`, `bug`, `chore`.
- `done` requiere gates: `review/security/qa`. - `done` requiere gates: `review/security/qa`.
- `done` requiere commit+push final del ticket.
- Evidencia en `work/artifacts/<feature_id>/`. - Evidencia en `work/artifacts/<feature_id>/`.

View File

@@ -1,11 +1,25 @@
{ {
"id": "F-001", "id": "F-001",
"title": "Bootstrap de proyecto con ARNES", "type": "chore",
"description": "Configurar pipeline SDD en este repositorio y validar primer ciclo completo.", "title": "Bootstrap ARNES on project",
"problem": "Need base workflow and control",
"goal": "Make ARNES ready on this repo",
"scope_in": [
"Harness setup",
"Runtime status",
"First verify cycle"
],
"scope_out": [
"Business feature work",
"Product redesign"
],
"priority": "med",
"risk": "low",
"description": "Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.",
"acceptance": [ "acceptance": [
"verify.sh en verde", "verify.sh is green",
"runtime-status operativo", "runtime status works",
"primera feature cerrada con gates" "first feature closes with gates"
], ],
"status": "pending", "status": "pending",
"created_at": "YYYY-MM-DD", "created_at": "YYYY-MM-DD",

View File

@@ -1 +0,0 @@
"""Unit tests init."""

View File

@@ -1 +0,0 @@
"""Unit tests init."""

View File

@@ -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()

View File

@@ -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