diff --git a/AGENTS.local.md.example b/AGENTS.local.md.example index 8bb47a9..f1c0761 100644 --- a/AGENTS.local.md.example +++ b/AGENTS.local.md.example @@ -1,9 +1,9 @@ -# AGENTS.local.md (ejemplo opcional) +# AGENTS.local.md (optional example) -Este archivo define reglas específicas del proyecto actual. +Use this file for project-specific rules only. -## Ejemplo -- Stack: FastAPI + PostgreSQL -- Deploy: Kubernetes -- Regla extra: toda migración requiere evidencia en `work/artifacts//db.md` -- Regla extra: `scripts/verify.local.sh` debe ejecutar `alembic check` y `pytest -m smoke` +## Example +- App dir: `project/` +- Deploy target: staging Kubernetes cluster +- Extra rule: DB changes require `work/artifacts//db.md` +- Extra rule: `scripts/verify.local.sh` must run smoke tests diff --git a/AGENTS.md b/AGENTS.md index 9437dd3..dcc0071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,13 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en curso. ## Arranque obligatorio -1. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`. -2. Leer `work/current.md`. -3. Leer `backlog/features.json` y seleccionar **una** feature `pending`. -4. Ejecutar `./scripts/verify.sh`. -5. Mostrar estado runtime: `python3 scripts/agent_status.py show`. -6. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`. +1. Usar ARNES dentro de un repo de proyecto real, no dentro del repo fuente de ARNES. +2. Si es primer uso en proyecto: ejecutar `./scripts/start.sh`. +3. Leer `work/current.md`. +4. Leer `backlog/features.json` y seleccionar **una** feature `pending`. +5. Ejecutar `./scripts/verify.sh`. +6. Mostrar estado runtime: `python3 scripts/agent_status.py show`. +7. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`. ## Ticket creation policy - Tickets are created by `leader` (or `triager`) only. @@ -30,6 +31,7 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en - `implementer` nunca marca `done`. - `done` requiere gates aprobados: `reviewer`, `security`, `qa`. - `done` requiere evidencia de `documenter`: `work/artifacts//documenter.md`. +- `done` requiere publish final con commit+push del ticket: `work/artifacts//publish.json`. - Si `verify.sh` falla, no se cierra la feature. ## Modelo por tarea (token-aware) @@ -37,6 +39,11 @@ Este repositorio es un **template genérico** para cualquier proyecto nuevo o en - Routing config: `harness/models.profiles.yml` - Rules: `harness/policies/model-routing.md` +## Git publish por ticket +- Al terminar una feature/ticket, `leader` debe ejecutar: + - `python3 scripts/publish_ticket.py --feature-id F-123` +- Esto crea commit + push del ticket y deja evidencia en `work/artifacts//publish.json`. + ## Extensión por proyecto (overlay) - Opcional: `AGENTS.local.md` para reglas específicas del proyecto actual. - Opcional: `scripts/verify.local.sh` para checks de dominio. diff --git a/CHECKPOINTS.md b/CHECKPOINTS.md index 43da744..6ae8849 100644 --- a/CHECKPOINTS.md +++ b/CHECKPOINTS.md @@ -1,11 +1,13 @@ # CHECKPOINTS ## C1 — Estructura -- [ ] Existe `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +- [ ] Existe `project/`, `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +- [ ] `project/README.md` existe como placeholder mínimo. ## C2 — Estado - [ ] Máximo una feature en `in_progress`. - [ ] Estados válidos en backlog. +- [ ] Tipos de ticket válidos en backlog. - [ ] `work/runtime-status.json` válido y visible con `scripts/agent_status.py`. ## C3 — Gates @@ -14,6 +16,7 @@ - [ ] Toda feature `done` tiene `qa.json` aprobado. - [ ] Toda feature `done` tiene `leader-close.json` válido. - [ ] Toda feature `done` tiene `documenter.md`. +- [ ] Toda feature `done` tiene `publish.json` con commit+push del ticket. ## C4 — Verificación - [ ] `./scripts/verify.sh` termina en OK. diff --git a/HOWTO-FEATURE.md b/HOWTO-FEATURE.md index 3853658..3240676 100644 --- a/HOWTO-FEATURE.md +++ b/HOWTO-FEATURE.md @@ -1,228 +1,41 @@ -# Cómo crear una Feature con SDD y BDD +# HOWTO-FEATURE — Crear una feature con SDD y BDD -Guía paso a paso para crear una feature usando System Design Document y Behavior Driven Development. +## Flujo corto +1. Crear ticket en backlog (`python3 scripts/new_ticket.py`) +2. `design` (architect) +3. `build` (implementer) +4. `review/security/qa` +5. `documentation_gate` +6. `close` +7. `publish` (`python3 scripts/publish_ticket.py --feature-id F-001`) ---- +## Artefactos esperados +- `work/artifacts//triage.md` (opcional) +- `work/artifacts//architect.md` (opcional) +- `work/artifacts//implementer.md` +- `work/artifacts//reviewer.json` +- `work/artifacts//security.json` +- `work/artifacts//qa.json` +- `work/artifacts//documenter.md` +- `work/artifacts//leader-close.json` +- `work/artifacts//publish.json` -## 📋 Flujo general +## Ticket style +- English caveman +- short title +- short acceptance bullets +- clear scope in/out -``` -1. Analizar la feature del backlog - ↓ -2. Crear SPEC/BBD (architect) - ↓ -3. Crear/actualizar SDD (architect) - ↓ -4. Generar código + tests (implementer) - ↓ -5. Review, Security, QA gates - ↓ -6. Cerrar feature -``` +## BDD notes +- Put `.feature` files in `spec/bdd/features/` +- Put steps in `features/steps/` +- Use tags like `@F-001`, `@smoke`, `@regression` ---- - -## Paso 1: Analizar del Backlog - -Ejemplo: F-002 "Gestión de Perfil de Usuario" - -```json -{ - "id": "F-002", - "title": "Gestión de Perfil de Usuario", - "description": "El usuario puede ver y editar su perfil (nombre, avatar, preferencias).", - "acceptance": [ - "Usuario puede ver su perfil", - "Usuario puede editar nombre y avatar", - "Usuario puede cambiar preferencias de idioma", - "Validación de datos en todos los campos" - ] -} -``` - ---- - -## Paso 2: Crear SDD (System Design Document) - -### 2.1 Crear componente - -Archivo: `spec/sdd/components/user-profile-service.md` - -```markdown -# Component: UserProfileService - -## Responsabilidad -Gestionar el perfil de usuario: consulta, actualización de datos básicos y preferencias. - -## Tipo -- [x] Microservicio - -## Interfaces - -### API REST - -``` -GET /api/v1/users/{user_id}/profile -Output: { "id", "name", "avatar_url", "language", "created_at" } - -PUT /api/v1/users/{user_id}/profile -Input: { "name": string, "avatar_url": string, "language": string } -Output: { "id", "name", "avatar_url", "language", "updated_at" } -``` - -## Validaciones -- name: 2-50 caracteres, sin caracteres especiales -- avatar_url: URL válida (http/https) -- language: enum ['en', 'es', 'fr', 'de'] -``` - -### 2.2 Crear ADR (si hay decisión técnica) - -Archivo: `spec/sdd/decisions/002-almacenamiento-avatar.md` - ---- - -## Paso 3: Crear BDD (Behavior Driven Development) - -### 3.1 Crear archivo .feature - -Archivo: `spec/bdd/features/profile/user-profile.feature` - -```gherkin -@F-002 @profile -Feature: Gestión de Perfil de Usuario - - Como usuario autenticado - Quiero gestionar mi perfil - Para mantener mis datos actualizados - - @smoke - Scenario: Ver perfil de usuario - Given un usuario autenticado con ID "user-123" - When el usuario solicita ver su perfil - Then el sistema retorna datos del perfil - And incluye nombre, avatar y preferencias - - Scenario: Editar nombre del perfil - Given un usuario autenticado con ID "user-123" - And el perfil tiene nombre "Juan" - When el usuario actualiza su nombre a "Pedro" - Then el perfil muestra nombre "Pedro" - And la fecha de actualización se registra - - @negative - Scenario: Editar nombre con caracteres inválidos - Given un usuario autenticado - When intenta cambiar nombre a "Juan@123!" - Then el sistema muestra error "Nombre inválido" - And el nombre permanece sin cambios - - Scenario: Cambiar idioma a español - Given un usuario con idioma "en" - When cambia idioma a "es" - Then toda la interfaz se muestra en español - And el preference se guarda correctamente -``` - -### 3.2 Escribir Step Definitions - -Archivo: `features/steps/profile_steps.py` - -```python -from behave import given, when, then - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated(context, user_id): - context.user_id = user_id - context.auth_token = f"token_{user_id}" - -@when('el usuario solicita ver su perfil') -def step_get_profile(context): - profile_service = ProfileService() - context.profile = profile_service.get_profile(context.user_id) - -@then('el sistema retorna datos del perfil') -def step_return_profile(context): - assert context.profile is not None - assert "name" in context.profile - -# ... más steps -``` - ---- - -## Paso 4: Ejecutar el pipeline ARNES - -### Stage: design (architect) -- ✅ Crea SDD component -- ✅ Crea BDD feature -- ✅ Produces `work/artifacts/F-002/architect.md` - -### Stage: build (implementer) -- Implementa `UserProfileService` -- Escribe step definitions -- Ejecuta `behave` para verificar - -### Stage: review_gate (reviewer) -- Verifica código coincide con SDD -- Verifica BDD coverage - -### Stage: security_gate (security) -- Check secrets, dependencies -- SAST scan - -### Stage: qa_gate (qa) -- Ejecuta BDD scenarios -- Verifica trazabilidad - -### Stage: close (leader) -- Verifica todos los gates en verde -- Produce `leader-close.json` - ---- - -## 📁 Archivos generados - -``` -spec/ -├── sdd/ -│ └── components/ -│ └── user-profile-service.md # Componente SDD -│ └── decisions/ -│ └── 002-almacenamiento-avatar.md # ADR (si aplica) -│ -├── bdd/ -│ └── features/ -│ └── profile/ -│ └── user-profile.feature # Feature BDD - -features/ -└── steps/ - └── profile_steps.py # Step definitions -``` - ---- - -## 🚀 Comandos para ejecutar - -```bash -# Verificar estructura -./scripts/verify.sh - -# Ejecutar tests BDD para la feature -behave spec/bdd/features/profile/user-profile.feature - -# Ejecutar solo scenarios con tag -behave spec/bdd/features/profile/user-profile.feature --tags @smoke -``` - ---- - -## Checklist - -- [ ] SDD component creado en `spec/sdd/components/` -- [ ] BDD feature creado en `spec/bdd/features//` -- [ ] Steps implementados en `features/steps/` -- [ ] Todos los scenarios tienen Given/When/Then -- [ ] Tags `@F-XXX` presentes en feature -- [ ] SDD/BDD linkeados en artefacto architect \ No newline at end of file +## Close rule +Feature can be `done` only if: +- review approved +- security approved +- qa approved +- documenter evidence exists +- publish evidence exists (`publish.json`) +- `./scripts/verify.sh` is green diff --git a/HOWTO.md b/HOWTO.md index 07f4ac6..6a6c46d 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -5,13 +5,15 @@ ```bash mkdir mi-proyecto && cd mi-proyecto git init -# copiar contenido de arnes-fork aquí +# instalar/copiAR ARNES dentro de este repo de proyecto +/path/to/arnes/scripts/install_into_repo.sh . ./scripts/start.sh ./scripts/verify.sh python3 scripts/agent_status.py show ``` Después: +- Mete tu código dentro de `project/` (o indica otra ruta en el wizard). - Edita `backlog/features.json` (`project`, `description`). - Crea tu primera feature `pending` (puedes usar `starter-pack/backlog.features.bootstrap.json`). - Empieza el ciclo SDD (una feature a la vez). @@ -20,7 +22,13 @@ Después: ## 2) Proyecto ya empezado (brownfield) -Copia al repo existente solo el core ARNES: +Copia al repo existente solo el core ARNES y coloca el código real en `project/` (o usa otra ruta al lanzar el wizard). Recomendado: + +```bash +/path/to/arnes/scripts/install_into_repo.sh . +``` + +Contenido core: - `harness/` - `spec/` - `backlog/` @@ -47,6 +55,17 @@ Crear ticket nuevo (leader/triager, EN caveman): python3 scripts/new_ticket.py ``` +Tipos soportados: +- `feature` +- `fix` +- `bug` +- `chore` + +Al final del ticket: +```bash +python3 scripts/publish_ticket.py --feature-id F-001 +``` + Modelo por tarea: - Config base en `harness/models.profiles.yml` - Reglas en `harness/policies/model-routing.md` @@ -54,5 +73,6 @@ Modelo por tarea: ## Reglas operativas mínimas - Máximo una feature en `in_progress`. - `done` requiere gates `review/security/qa` aprobados. +- `done` requiere publish final con commit+push del ticket. - Evidencia siempre en `work/artifacts//`. - Si `verify.sh` falla, no se cierra la feature. diff --git a/Makefile b/Makefile index ffe9e16..a450d7e 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,4 @@ -.PHONY: run run-dev test verify start ticket clean - -# Puerto por defecto -PORT?=8000 - -run: - @echo "Arrancando ARNES API en http://localhost:$(PORT)/ui/login.html" - @echo "Credenciales: alice@example.com / SecurePass123!" - python3 -m uvicorn src.main:app --host 0.0.0.0 --port $(PORT) - -run-dev: - @echo "Arrancando en modo desarrollo (auto-reload)..." - python3 -m uvicorn src.main:app --reload --port $(PORT) - -test: - python3 -m unittest discover -s tests +.PHONY: verify start ticket publish install clean help verify: ./scripts/verify.sh @@ -24,22 +9,24 @@ start: ticket: python3 scripts/new_ticket.py +publish: + @test -n "$(FEATURE_ID)" || (echo "Use: make publish FEATURE_ID=F-001" && exit 1) + python3 scripts/publish_ticket.py --feature-id $(FEATURE_ID) + +install: + @test -n "$(TARGET)" || (echo "Use: make install TARGET=/path/to/project-repo" && exit 1) + ./scripts/install_into_repo.sh $(TARGET) + clean: find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true -# Help help: - @echo "ARNES UI API - Comandos disponibles:" + @echo "ARNES template - commands:" @echo "" - @echo " make run - Arrancar servidor (puerto 8000)" - @echo " make run PORT=8080 - Arrancar en puerto específico" - @echo " make run-dev - Arrancar con auto-reload" - @echo " make test - Ejecutar tests unitarios" - @echo " make verify - Verificar harness" - @echo " make start - Wizard de inicio de proyecto" - @echo " make ticket - Crear ticket (EN caveman)" - @echo " make clean - Limpiar cache" - @echo "" - @echo "URLs:" - @echo " http://localhost:8000/ui/login.html" \ No newline at end of file + @echo " make verify - Verify harness core" + @echo " make start - First-run wizard" + @echo " make ticket - Create ticket (EN caveman)" + @echo " make publish FEATURE_ID=.. - Commit and push one ticket" + @echo " make install TARGET=.. - Install ARNES into external repo" + @echo " make clean - Clean cache files" diff --git a/README-UI.md b/README-UI.md deleted file mode 100644 index 82a74aa..0000000 --- a/README-UI.md +++ /dev/null @@ -1,57 +0,0 @@ -# ARN-UI API - -API de autenticación con UI integrada. - -## Instalación - -```bash -pip install -r requirements.txt -``` - -## Arrancar - -```bash -# Modo desarrollo (reload automático) -python3 -m uvicorn src.main:app --reload --port 8000 - -# Modo producción -python3 -m uvicorn src.main:app --host 0.0.0.0 --port 8000 -``` - -## Endpoints - -| Método | Endpoint | Descripción | -|--------|----------|-------------| -| GET | `/` | Redirige a UI de login | -| GET | `/health` | Health check | -| POST | `/api/v1/auth/login` | Login con email/password | -| POST | `/api/v1/auth/logout` | Cerrar sesión | -| POST | `/api/v1/auth/refresh` | Refrescar token | -| GET | `/api/v1/auth/validate` | Validar token | -| GET | `/ui/login.html` | Página de login | -| GET | `/ui/dashboard.html` | Dashboard del usuario | -| GET | `/ui/change-password.html` | Cambiar contraseña | - -## Usuarios de prueba - -| Email | Password | -|-------|----------| -| alice@example.com | SecurePass123! | - -## Variables de entorno - -| Variable | Default | Descripción | -|----------|---------|-------------| -| JWT_SECRET | dev-secret-key-change-in-prod | Clave para firmar JWT | - -## Producción - -Para producción, usar: -```bash -uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -O con Gunicorn: -```bash -gunicorn src.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -``` \ No newline at end of file diff --git a/README.md b/README.md index 3d20bc8..0bc8691 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # ARNES Framework (agnóstico) — Diseño v0.1 Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad. + +Convención recomendada: el código real del proyecto vive dentro de `project/`. +Cada proyecto real debe vivir en **su propio repo git**, distinto del repo fuente de ARNES. Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores. --- @@ -67,6 +70,7 @@ Permitir que agentes implementen features de forma autónoma **sin perder contro 7. `qa_gate` (qa) ✅ 8. `documentation_gate` (documenter) ✅ 9. `close` (leader) +10. `publish` (leader) ✅ **Regla:** no hay `done` si cualquier gate falla. @@ -86,6 +90,7 @@ Cada agente escribe artefactos en disco: - `work/artifacts//security.json` - `work/artifacts//qa.json` - `work/artifacts//leader-close.json` +- `work/artifacts//publish.json` Respuesta de agente siempre: `done -> ` o `blocked -> `. @@ -104,29 +109,38 @@ Respuesta de agente siempre: `done -> ` o `blocked -> `. ```text . +├── project/ # código real del proyecto +│ └── README.md ├── README.md +├── AGENTS.md +├── CHECKPOINTS.md ├── harness/ │ ├── agents.matrix.yml │ ├── workflow.stages.yml +│ ├── models.profiles.yml │ ├── policies/ -│ │ ├── security.md -│ │ ├── quality.md -│ │ └── governance.md │ └── contracts/ -│ ├── handoff.md -│ └── evidence.schema.json ├── spec/ │ ├── product.md │ ├── tech.md -│ └── acceptance.md +│ ├── acceptance.md +│ ├── sdd/ +│ └── bdd/ ├── backlog/ │ └── features.json ├── work/ │ ├── current.md │ ├── history.md +│ ├── runtime-status.json │ └── artifacts/ -└── scripts/ - └── verify.sh +├── scripts/ +│ ├── start.sh +│ ├── new_ticket.py +│ ├── agent_status.py +│ └── verify.sh +├── defaults/ +│ └── flask-skeleton/ +└── platforms/ ``` --- @@ -186,16 +200,44 @@ El núcleo no cambia; solo el adaptador. ## Inicio rápido +- Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo` - Ejecuta wizard: `./scripts/start.sh` - Crear ticket: `python3 scripts/new_ticket.py` +- Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001` - Guía breve: `HOWTO.md` - Starter pack: `starter-pack/README.md` - Adaptación del template: `TEMPLATE.md` +- Layout del repo: `docs/repository-layout.md` +- Referencia de scripts: `docs/scripts-reference.md` - Manual Skeleton (uso + mejoras): `docs/skeleton-manual.md` +## Tipos de tarea / ticket + +`python3 scripts/new_ticket.py` soporta estos tipos: + +- `feature`: nueva capacidad +- `fix`: corrección de comportamiento roto +- `bug`: incidencia reportada o defecto claro +- `chore`: trabajo interno, refactor, setup, mantenimiento + +Además guarda campos estructurados: +- `problem` +- `goal` +- `scope_in` +- `scope_out` +- `priority` +- `risk` +- `acceptance` + +Convención recomendada: +- usar `feature` para trabajo nuevo visible +- usar `fix` o `bug` para reparación +- usar `chore` para cambios internos sin valor funcional directo + ## Próximos pasos sugeridos -1. Definir el backlog inicial del proyecto real. -2. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`). -3. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`. -4. Empezar la primera feature `pending` con pipeline completo. +1. Instalar/copiar ARNES en un repo de proyecto real distinto del repo fuente. +2. Definir el backlog inicial del proyecto real. +3. Configurar overlay opcional (`AGENTS.local.md`, `scripts/verify.local.sh`). +4. Ejecutar `./scripts/verify.sh` y `python3 scripts/agent_status.py show`. +5. Empezar la primera feature `pending` con pipeline completo y terminar con commit+push del ticket. diff --git a/TEMPLATE.md b/TEMPLATE.md index 718ffae..0c7a395 100644 --- a/TEMPLATE.md +++ b/TEMPLATE.md @@ -1,6 +1,7 @@ # TEMPLATE.md — Cómo adaptar ARNES a cualquier proyecto ## 1) Clonar y renombrar contexto +- Pon el código real dentro de `project/` (o elige otra ruta en `./scripts/start.sh`). - Ajusta `backlog/features.json` (`project`, `description`). - Crea primeras features reales en `features[]`. @@ -8,16 +9,19 @@ - Opcional: crea `AGENTS.local.md` con reglas del dominio. - Opcional: crea `scripts/verify.local.sh` con checks propios del stack. - Mantén tickets y órdenes internas en English caveman (`harness/policies/language.md`). +- Usa tipos de ticket consistentes: `feature`, `fix`, `bug`, `chore`. - Ajusta routing de modelos por rol/tarea en `harness/models.profiles.yml`. ## 3) Flujo estándar +0. Instalar ARNES en repo externo: `./scripts/install_into_repo.sh /path/to/project-repo` 1. `./scripts/start.sh` (primer uso) 2. `python3 scripts/new_ticket.py` (leader/triager) 3. `python3 scripts/agent_status.py show` 4. Seleccionar 1 feature `pending` y pasarla a `in_progress` 5. Implementar con artefactos en `work/artifacts//` -6. Cerrar solo con gates `review/security/qa` + `documenter` aprobados -7. `python3 scripts/agent_status.py reset` +6. Cerrar con gates `review/security/qa` + `documenter` aprobados +7. Publicar ticket: `python3 scripts/publish_ticket.py --feature-id F-001` +8. `python3 scripts/agent_status.py reset` ## 4) Contrato de cierre - `status=done` exige: @@ -25,6 +29,7 @@ - `security.json` APPROVED - `qa.json` APPROVED - `leader-close.json` APPROVED + - `publish.json` PUBLISHED - `./scripts/verify.sh` OK ## 5) Principio de template diff --git a/backlog/features.json b/backlog/features.json index a332c94..6b9eb57 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -11,15 +11,32 @@ "in_progress", "blocked", "done" + ], + "valid_types": [ + "feature", + "fix", + "bug", + "chore" ] }, "template_feature_schema": { "id": "F-001", - "title": "Título de la feature", - "description": "Descripción funcional", + "type": "feature", + "title": "Short ticket title", + "problem": "Need change", + "goal": "Make flow better", + "scope_in": [ + "Core flow" + ], + "scope_out": [ + "No redesign" + ], + "priority": "med", + "risk": "low", + "description": "Problem: ... Goal: ... Scope IN: ... Scope OUT: ... Type: ... Priority: ... Risk: ...", "acceptance": [ - "Criterio 1", - "Criterio 2" + "Flow works end to end", + "No break old behavior" ], "status": "pending", "created_at": "YYYY-MM-DD", diff --git a/docs/repository-layout.md b/docs/repository-layout.md new file mode 100644 index 0000000..3abbfd5 --- /dev/null +++ b/docs/repository-layout.md @@ -0,0 +1,38 @@ +# Repository layout + +## Core idea +- ARNES core lives at repository root once installed into a project repo. +- The source repo of ARNES is not the repo where product work should happen. +- Real project code lives in `project/` by default. +- Project-specific rules live in overlays, not in core files. + +## Main directories +- `project/` — real app code +- `backlog/` — ticket list and feature state +- `work/` — runtime state, history, artifacts +- `harness/` — workflow, roles, policies, contracts +- `spec/` — product, tech, acceptance, SDD, BDD source docs +- `features/` — optional executable BDD runner assets +- `scripts/` — start, verify, ticket creation, runtime status +- `platforms/` — platform adapters (pi, opencode) +- `defaults/` — optional starter assets + +## Recommended separation +- Core ARNES should stay generic. +- Domain checks go in `scripts/verify.local.sh`. +- Domain rules go in `AGENTS.local.md`. +- Real code should not be mixed into `harness/`, `work/`, `backlog/`, or `spec/`. + +## Default project shape +```text +project/ +├── README.md +├── templates/ +├── static/ +│ ├── css/ +│ ├── js/ +│ └── images/ +└── ... +``` + +This shape is only a default. The wizard can target another app directory if needed. diff --git a/docs/scripts-reference.md b/docs/scripts-reference.md new file mode 100644 index 0000000..320d06a --- /dev/null +++ b/docs/scripts-reference.md @@ -0,0 +1,66 @@ +# Scripts reference + +## `./scripts/install_into_repo.sh /path/to/project-repo` +Copies ARNES core into a different project repo. + +What it does: +- refuses to install into the ARNES source repo itself +- initializes git repo at target if missing +- copies ARNES core files into target repo + +## `./scripts/start.sh` +Interactive bootstrap wizard. + +What it does: +- asks project metadata +- chooses default app directory (`project/` by default) +- writes `harness/project.config.json` +- creates `scripts/verify.local.sh` +- can seed one bootstrap ticket +- resets runtime status + +## `./scripts/verify.sh` +Core harness verification. + +What it checks: +- required core files exist +- project is inside a git repo +- warns if no git remote exists +- backlog JSON is valid +- only one feature is `in_progress` +- done features have all required artifacts, including publish evidence +- runtime status JSON is valid +- optional local overlay runs if present + +## `python3 scripts/new_ticket.py` +Interactive ticket creator. + +Writes one new backlog entry with: +- `type` +- `title` +- `problem` +- `goal` +- `scope_in` +- `scope_out` +- `priority` +- `risk` +- `acceptance` + +## `python3 scripts/publish_ticket.py --feature-id F-001` +Final publish step for one ticket. + +What it does: +- validates git repo, remote, and git identity +- writes `work/artifacts//publish.json` +- creates one commit for the ticket +- pushes the branch to remote + +## `python3 scripts/agent_status.py` +Runtime status helper. + +Commands: +- `show` +- `set` +- `reset` + +The `set` command validates stage and agent names against harness files. diff --git a/features/README.md b/features/README.md new file mode 100644 index 0000000..08a38e9 --- /dev/null +++ b/features/README.md @@ -0,0 +1,11 @@ +# Executable BDD assets + +This directory is for executable BDD helpers. + +Recommended split: +- `spec/bdd/features/` = source-of-truth scenarios in Gherkin +- `features/steps/` = executable step definitions and runner config +- `features/behave.ini` = Behave runner config + +Keep feature text in `spec/bdd/features/`. +Keep runner-specific code in `features/`. diff --git a/features/behave.ini b/features/behave.ini index 41911f1..c225911 100644 --- a/features/behave.ini +++ b/features/behave.ini @@ -1,10 +1,8 @@ [behave] paths = features/ format = pretty -tags = @F-001 -# Para ejecutar solo smoke tests: +# Examples: +# behave features/ # behave features/ --tags @smoke - -# Para excluir tests lentos: -# behave features/ --tags ~@slow \ No newline at end of file +# behave features/ --tags ~@slow diff --git a/features/steps/.gitkeep b/features/steps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/features/steps/auth_steps.py b/features/steps/auth_steps.py deleted file mode 100644 index 246604a..0000000 --- a/features/steps/auth_steps.py +++ /dev/null @@ -1,198 +0,0 @@ -from behave import given, when, then -from pydantic import BaseModel - - -class User(BaseModel): - email: str - password: str - name: str | None = None - - -class AuthService: - def __init__(self): - self.users_db: dict[str, User] = {} - self.sessions: dict[str, str] = {} - - def register(self, email: str, password: str, name: str = "") -> dict: - if email in self.users_db: - raise ValueError("Email already exists") - - self.users_db[email] = User(email=email, password=password, name=name) - token = f"token_{email}" - self.sessions[token] = email - return {"user_id": email, "token": token} - - def login(self, email: str, password: str) -> dict: - user = self.users_db.get(email) - if not user or user.password != password: - raise ValueError("Invalid credentials") - - token = f"token_{email}" - self.sessions[token] = email - return {"user_id": email, "token": token} - - def logout(self, token: str) -> bool: - if token in self.sessions: - del self.sessions[token] - return True - return False - - def has_active_session(self, token: str) -> bool: - return token in self.sessions - - -# Global service instance for tests -auth_service = AuthService() - - -@given('un usuario registrado con email "{email}" y password "{password}"') -def step_registered_user(context, email, password): - """Crea usuario de prueba en el sistema.""" - try: - auth_service.register(email, password, name="Test User") - except ValueError: - pass # Already exists - - -@given('un usuario no registrado con email "{email}"') -def step_unregistered_user(context, email): - """Verifica que el usuario no existe.""" - if email in auth_service.users_db: - del auth_service.users_db[email] - - -@given('el usuario no tiene sesión activa') -def step_no_active_session(context): - """Limpia cualquier sesión activa.""" - context.token = None - - -@when('el usuario navega a la página de login') -def step_navigate_to_login(context): - """Simula navegación a login.""" - context.page = "login" - - -@when('el usuario ingresa su email "{email}"') -def step_enter_email(context, email): - """Ingresa email en el formulario.""" - context.email_input = email - - -@when('ingresa password "{password}"') -def step_enter_password(context, password): - """Ingresa password.""" - context.password_input = password - - -@when('el usuario ingresa email "{email}"') -def step_ingresa_email(context, email): - """Variante: ingresa email.""" - context.email_input = email - - -@when('ingresa password incorrecta "{password}"') -def step_ingresa_password_incorrecto(context, password): - """Variante: ingresa password incorrecto.""" - context.password_input = password - - -@when('deja el campo de password vacío') -def step_password_vacio(context): - """Campo de password vacío.""" - context.password_input = "" - - -@when('presiona el botón "Iniciar sesión"') -def step_press_login_button(context): - """Intenta hacer login.""" - try: - result = auth_service.login(context.email_input, context.password_input) - context.token = result.get("token") - context.login_success = True - except ValueError as e: - context.error_message = str(e) - context.login_success = False - - -@then('el sistema autentica al usuario') -def step_authenticate(context): - """Verifica que el usuario fue autenticado.""" - assert context.login_success, "Login should succeed" - assert context.token is not None, "Token should be generated" - - -@then('redirige a la página del dashboard') -def step_redirect_dashboard(context): - """Verifica redirección a dashboard.""" - assert context.token is not None, "Should have token for authenticated user" - - -@then('muestra un toast de bienvenida con su nombre') -def step_show_welcome_toast(context): - """Verifica toast de bienvenida.""" - assert context.token is not None, "Should show welcome for authenticated user" - - -@then('el sistema muestra mensaje de error "{expected_message}"') -def step_show_error_message(context, expected_message): - """Verifica mensaje de error específico.""" - assert not context.login_success, "Login should fail" - assert context.error_message == expected_message, f"Expected '{expected_message}', got '{context.error_message}'" - - -@then('el usuario permanece en la página de login') -def step_remains_in_login(context): - """Verifica que permanece en login.""" - assert context.page == "login" or not context.login_success - - -@then('el campo de password está vacío') -def step_password_empty(context): - """Verifica que password se limpió.""" - assert context.password_input == "" - - -@then('el sistema sanitiza el input') -def step_sanitize_input(context): - """Verifica sanitización de input malicioso.""" - # El servicio debe rechazar inyecciones - malicious_email = context.email_input if hasattr(context, 'email_input') else "" - assert "'" not in malicious_email or "@" in malicious_email - - -@then('muestra mensaje de error genérico') -def step_generic_error(context): - """Verifica mensaje de error genérico (no revelar detalles).""" - # Para seguridad, no mostrar si el email existe o no - pass - - -@then('no permite acceso al sistema') -def step_no_access(context): - """Verifica que no hay acceso.""" - assert context.token is None or not context.login_success - - -@when('el usuario hace clic en "¿Olvidaste tu contraseña?"') -def step_click_forgot_password(context): - """Clic en recuperación de password.""" - context.page = "recover_password" - - -@then('el sistema muestra formulario de recuperación') -def step_show_recovery_form(context): - """Verifica que muestra formulario.""" - assert context.page == "recover_password" - - -@then('el sistema envía email de recuperación') -def step_send_recovery_email(context): - """Simula envío de email.""" - context.email_sent = True - - -@then('muestra mensaje "Revisa tu bandeja de entrada"') -def step_show_check_inbox(context): - """Verifica mensaje de email enviado.""" - assert context.email_sent, "Email should be sent" \ No newline at end of file diff --git a/features/steps/password_steps.py b/features/steps/password_steps.py deleted file mode 100644 index 1ec327c..0000000 --- a/features/steps/password_steps.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Step definitions para Change Password BDD tests.""" -from behave import given, when, then -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Literal -import re - - -@dataclass -class User: - """User model for testing.""" - id: str - email: str - password_hash: str - sessions: list[str] = field(default_factory=list) - - -class PasswordValidator: - """Validates password strength requirements.""" - - MIN_LENGTH = 8 - MAX_LENGTH = 128 - - @classmethod - def validate(cls, password: str) -> tuple[bool, str]: - """Validate password strength. Returns (is_valid, error_message).""" - if len(password) < cls.MIN_LENGTH: - return False, "La contraseña debe tener al menos 8 caracteres" - if len(password) > cls.MAX_LENGTH: - return False, "La contraseña debe tener máximo 128 caracteres" - if not re.search(r'[A-Z]', password): - return False, "La contraseña debe contener al menos una mayúscula" - if not re.search(r'[a-z]', password): - return False, "La contraseña debe contener al menos una minúscula" - if not re.search(r'\d', password): - return False, "La contraseña debe contener al menos un número" - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password): - return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - return True, "" - - -class PasswordService: - """Service for password management.""" - - def __init__(self): - self.users: dict[str, User] = {} - self.password_history: dict[str, list[str]] = {} # user_id -> list of hashed passwords - self.rate_limits: dict[str, list[datetime]] = {} # user_id -> list of attempt times - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock data.""" - self.users = { - "user-123": User( - id="user-123", - email="user@example.com", - password_hash="OldPass123!" # In real app: bcrypt hash - ), - "user-456": User( - id="user-456", - email="other@example.com", - password_hash="OtherPass456!" - ) - } - - def _hash_password(self, password: str) -> str: - """Mock bcrypt hash.""" - return password # In real app: bcrypt.hashpw() - - def _verify_password(self, password: str, hashed: str) -> bool: - """Mock password verification.""" - return password == hashed # In real app: bcrypt.checkpw() - - def _is_rate_limited(self, user_id: str) -> bool: - """Check if user is rate limited (5 attempts per hour).""" - if user_id not in self.rate_limits: - return False - - # Clean old attempts - one_hour_ago = datetime.now() - timedelta(hours=1) - self.rate_limits[user_id] = [ - t for t in self.rate_limits[user_id] if t > one_hour_ago - ] - - return len(self.rate_limits[user_id]) >= 5 - - def _record_attempt(self, user_id: str): - """Record a password change attempt.""" - if user_id not in self.rate_limits: - self.rate_limits[user_id] = [] - self.rate_limits[user_id].append(datetime.now()) - - def _is_same_as_history(self, user_id: str, new_password: str) -> bool: - """Check if password was used recently.""" - if user_id not in self.password_history: - return False - - # Check last 3 passwords - recent_passwords = self.password_history[user_id][-3:] - return new_password in recent_passwords - - def change_password( - self, - user_id: str, - current_password: str, - new_password: str, - confirm_password: str, - is_authenticated: bool = True, - is_owner: bool = True - ) -> tuple[bool, int, str | None]: - """ - Change user password. - - Returns: (success, status_code, error_message) - """ - # Check authentication - if not is_authenticated: - return False, 401, "No autorizado" - - # Check authorization - if not is_owner: - return False, 403, "No tienes permiso para modificar esta cuenta" - - # Check rate limit - if self._is_rate_limited(user_id): - return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora" - - # Record attempt - self._record_attempt(user_id) - - # Check user exists - if user_id not in self.users: - return False, 404, "Usuario no encontrado" - - user = self.users[user_id] - - # Validate current password - if not current_password: - return False, 400, "La contraseña actual es requerida" - - if not self._verify_password(current_password, user.password_hash): - return False, 401, "La contraseña actual es incorrecta" - - # Validate passwords match - if new_password != confirm_password: - return False, 400, "Las contraseñas no coinciden" - - # Validate new password strength - is_valid, error = PasswordValidator.validate(new_password) - if not is_valid: - return False, 400, error - - # Check password history - if self._is_same_as_history(user_id, new_password): - return False, 400, "La nueva contraseña no puede ser igual a la anterior" - - # Change password - self.password_history.setdefault(user_id, []).append(new_password) - user.password_hash = self._hash_password(new_password) - - # Invalidate all sessions - user.sessions.clear() - - return True, 200, None - - -# Global service instance -password_service = PasswordService() - - -# ==================== -# GIVEN STEPS -# ==================== - -@given('un usuario autenticado con email "{email}"') -def step_user_authenticated_email(context, email): - """User authenticated with specific email.""" - context.is_authenticated = True - context.is_owner = True - # Find user by email - for uid, user in password_service.users.items(): - if user.email == email: - context.user_id = uid - context.user_email = email - context.current_password = user.password_hash - break - - -@given('un usuario autenticado') -def step_user_authenticated(context): - """User authenticated (generic).""" - context.is_authenticated = True - context.is_owner = True - context.user_id = "user-123" - - -@given('su contraseña actual es "{password}"') -def step_current_password(context, password): - """Set current password.""" - context.current_password_input = password - if hasattr(context, 'user_id') and context.user_id in password_service.users: - password_service.users[context.user_id].password_hash = password - - -@given('un usuario no autenticado') -def step_user_not_authenticated(context): - """User not authenticated.""" - context.is_authenticated = False - - -@given('un usuario con contraseña actual "{password}"') -def step_user_with_password(context, password): - """User with specific current password.""" - context.current_password_input = password - - -@given('historial de contraseñas incluye "{password}"') -def step_password_in_history(context, password): - """Add password to user's history.""" - if hasattr(context, 'user_id'): - password_service.password_history.setdefault(context.user_id, []).append(password) - - -@given('un usuario con sesión expirada') -def step_user_expired_session(context): - """User with expired session.""" - context.is_authenticated = False - context.token_expired = True - - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated_id(context, user_id): - """User authenticated with specific ID.""" - context.is_authenticated = True - context.is_owner = True - context.user_id = user_id - - -@given('ya realizó {count} intentos fallidos en la última hora') -def step_rate_limited_user(context, count): - """User has exceeded rate limit.""" - context.user_id = "user-123" - password_service.rate_limits[context.user_id] = [ - datetime.now() - timedelta(minutes=i) for i in range(int(count)) - ] - - -@given('un usuario con contraseña "{password}"') -def step_user_with_specific_password(context, password): - """User with specific password.""" - context.user_id = "user-123" - password_service.users[context.user_id].password_hash = password - - -# ==================== -# WHEN STEPS -# ==================== - -@when('el usuario solicita cambiar contraseña') -def step_request_change_password(context): - """User requests password change.""" - context.password_change_requested = True - - -@when('ingresa contraseña actual "{password}"') -def step_enter_current_password(context, password): - """Enter current password.""" - context.current_password_input = password - - -@when('intenta cambiar contraseña con actual "{password}"') -def step_try_with_current_password(context, password): - """Try to change password with specific current password.""" - context.current_password_input = password - - -@when('ingresa nueva contraseña "{password}"') -def step_enter_new_password(context, password): - """Enter new password.""" - context.new_password_input = password - - -@when('intenta cambiar contraseña a "{password}"') -def step_try_change_to_password(context, password): - """Try to change to specific password.""" - context.new_password_input = password - context.confirm_password_input = password - - -@when('intenta cambiar contraseña a "{prefix}" repetido {count} veces más "{suffix}"') -def step_try_change_long_password(context, prefix, count, suffix): - """Try to change to very long password.""" - long_password = prefix * (int(count) + 1) + suffix - context.new_password_input = long_password - context.confirm_password_input = long_password - - -@when('confirma nueva contraseña "{password}"') -def step_confirm_password(context, password): - """Confirm new password.""" - context.confirm_password_input = password - - -@when('ingresa contraseña actual correcta') -def step_enter_correct_current_password(context): - """Enter correct current password.""" - if hasattr(context, 'current_password'): - context.current_password_input = context.current_password - - -@when('pero confirma con "{password}"') -def step_confirm_different_password(context, password): - """Confirm with different password.""" - context.confirm_password_input = password - - -@when('luego intenta iniciar sesión con "{password}"') -def step_login_with_password(context, password): - """Try to login with new password.""" - context.login_password = password - context.login_user_id = context.user_id - - -@when('intenta cambiar contraseña') -def step_try_change_password(context): - """Try to change password (generic).""" - pass # Will be handled in then step - - -@when('intenta cambiar contraseña del usuario "{target_user_id}"') -def step_try_change_other_user_password(context, target_user_id): - """Try to change another user's password.""" - context.user_id = target_user_id - context.is_owner = False - - -@when('intenta cambiar contraseña una vez más') -def step_try_one_more_time(context): - """Try one more time (rate limited).""" - pass - - -# ==================== -# THEN STEPS -# ==================== - -@then('el sistema valida la contraseña actual correctamente') -def step_validate_current_password(context): - """Validate current password correctly.""" - if hasattr(context, 'new_password_input'): - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input, - context.confirm_password_input or context.new_password_input, - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - -@then('guarda la nueva contraseña hasheada') -def step_save_hashed_password(context): - """Save new hashed password.""" - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user: - # Password was changed, verify it's different - assert user.password_hash != context.current_password_input - - -@then('invalida todas las sesiones existentes') -def step_invalidate_sessions(context): - """Invalidate all user sessions.""" - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user: - assert len(user.sessions) == 0, "Sessions should be cleared" - - -@then('muestra mensaje de confirmación "{message}"') -def step_show_confirmation_message(context, message): - """Show confirmation message.""" - assert context.password_change_success, f"Password change should succeed" - assert context.response_status == 200 - - -@then('el sistema acepta la contraseña') -def step_accept_password(context): - """System accepts the password.""" - if not hasattr(context, 'new_password_input'): - return - - is_valid, error = PasswordValidator.validate(context.new_password_input) - assert is_valid, f"Password should be valid but got: {error}" - - -@then('la guarda correctamente') -def step_save_correctly(context): - """Save password correctly.""" - pass # Handled by previous steps - - -@then('el sistema muestra error "{error_message}"') -def step_show_error(context, error_message): - """Show specific error message.""" - # Execute the change to get the error - if hasattr(context, 'new_password_input'): - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input, - context.confirm_password_input or context.new_password_input, - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - if context.response_error: - # Check if error contains expected message - assert error_message in context.response_error or context.response_status >= 400 - - -@then('la contraseña no es cambiada') -def step_password_not_changed(context): - """Password is not changed.""" - # Verify password wasn't changed - if hasattr(context, 'user_id'): - user = password_service.users.get(context.user_id) - if user and hasattr(context, 'current_password_input'): - # Password should remain unchanged - pass - - -@then('no se invalidan sesiones') -def step_sessions_not_invalidated(context): - """Sessions are not invalidated.""" - pass # If change failed, sessions should remain - - -@then('el sistema retorna error {status_code} "{error_message}"') -def step_return_http_error(context, status_code, error_message): - """Return HTTP error with specific status code.""" - # Execute the change - success, status, error = password_service.change_password( - context.user_id, - context.current_password_input or "", - context.new_password_input or "TestPass123!", - context.confirm_password_input or "TestPass123!", - is_authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.password_change_success = success - context.response_status = status - context.response_error = error - - assert status == int(status_code), f"Expected {status_code}, got {status}" - - -@then('el login es exitoso') -def step_login_successful(context): - """Login is successful with new password.""" - if hasattr(context, 'user_id') and hasattr(context, 'login_password'): - user = password_service.users.get(context.user_id) - if user: - assert user.password_hash == context.login_password, "New password should work" \ No newline at end of file diff --git a/features/steps/profile_steps.py b/features/steps/profile_steps.py deleted file mode 100644 index ffc9887..0000000 --- a/features/steps/profile_steps.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Step definitions para User Profile BDD tests.""" -from behave import given, when, then -from dataclasses import dataclass -from datetime import datetime -import re - - -@dataclass -class UserProfile: - """Modelo de perfil de usuario.""" - id: str - name: str - avatar_url: str - language: str - created_at: datetime = datetime.now() - updated_at: datetime = datetime.now() - - -class ProfileService: - """Servicio mock para tests BDD.""" - - def __init__(self): - self.profiles: dict[str, UserProfile] = {} - self._init_mock_data() - - def _init_mock_data(self): - """Datos mock para testing.""" - self.profiles = { - "user-123": UserProfile( - id="user-123", - name="Juan Pérez", - avatar_url="https://cdn.example.com/avatar-123.jpg", - language="es" - ), - "user-456": UserProfile( - id="user-456", - name="María García", - avatar_url="https://cdn.example.com/avatar-456.jpg", - language="en" - ) - } - - def get_profile(self, user_id: str, authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]: - """Obtiene perfil de usuario.""" - if not authenticated: - return None, 401, "No autorizado" - - if user_id not in self.profiles: - return None, 404, "Usuario no encontrado" - - return self.profiles[user_id], 200, None - - def update_profile(self, user_id: str, name: str | None = None, - avatar_url: str | None = None, language: str | None = None, - authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]: - """Actualiza perfil de usuario.""" - if not authenticated: - return None, 401, "No autorizado" - - if not is_owner: - return None, 403, "No tienes permiso para editar este perfil" - - if user_id not in self.profiles: - return None, 404, "Usuario no encontrado" - - profile = self.profiles[user_id] - - # Validaciones - if name is not None: - if len(name) < 2: - return None, 400, "Nombre debe tener al menos 2 caracteres" - if len(name) > 50: - return None, 400, "Nombre debe tener máximo 50 caracteres" - if not re.match(r'^[a-zA-ZáéíóúñÑ\s]+$', name): - return None, 400, "Nombre inválido: solo letras y espacios" - profile.name = name - - if avatar_url is not None: - if not avatar_url.startswith(('http://', 'https://')): - return None, 400, "Solo se permiten URLs http o https" - if not self._is_valid_url(avatar_url): - return None, 400, "URL de avatar inválida" - profile.avatar_url = avatar_url - - if language is not None: - valid_languages = ['en', 'es', 'fr', 'de'] - if language not in valid_languages: - return None, 400, "Idioma no soportado" - profile.language = language - - profile.updated_at = datetime.now() - return profile, 200, None - - def _is_valid_url(self, url: str) -> bool: - """Valida formato de URL.""" - pattern = r'^https?://[\w\-\.]+\.[a-zA-Z]{2,}(\/[\w\-\./]*)?$' - return bool(re.match(pattern, url)) - - -# Global service instance -profile_service = ProfileService() - - -# ==================== -# GIVEN STEPS -# ==================== - -@given('un usuario autenticado con ID "{user_id}" y nombre "{name}"') -def step_user_authenticated_with_name(context, user_id, name): - """Usuario autenticado con nombre específico.""" - context.user_id = user_id - context.auth_token = f"token_{user_id}" - context.is_authenticated = True - context.is_owner = True - # Ensure user exists in mock - if user_id not in profile_service.profiles: - profile_service.profiles[user_id] = UserProfile(id=user_id, name=name, avatar_url="", language="en") - - -@given('un usuario autenticado con ID "{user_id}"') -def step_user_authenticated(context, user_id): - """Usuario autenticado genérico.""" - context.user_id = user_id - context.auth_token = f"token_{user_id}" - context.is_authenticated = True - context.is_owner = True - - -@given('un usuario autenticado') -def step_user_authenticated_generic(context): - """Usuario autenticado sin ID específico.""" - context.is_authenticated = True - context.is_owner = True - context.user_id = "user-123" - - -@given('el usuario tiene avatar "{avatar_url}"') -def step_user_has_avatar(context, avatar_url): - """Usuario tiene avatar específico.""" - context.expected_avatar = avatar_url - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].avatar_url = avatar_url - - -@given('el idioma configurado es "{language}"') -def step_user_language(context, language): - """Idioma del usuario.""" - context.expected_language = language - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].language = language - - -@given('el perfil tiene nombre "{name}"') -def step_profile_has_name(context, name): - """El perfil tiene un nombre específico.""" - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].name = name - - -@given('un usuario no autenticado') -def step_user_not_authenticated(context): - """Usuario sin autenticación.""" - context.is_authenticated = False - - -@given('un usuario con idioma "{language}"') -def step_user_with_language(context, language): - """Usuario con idioma específico.""" - if context.user_id in profile_service.profiles: - profile_service.profiles[context.user_id].language = language - - -@given('un usuario con token expirado') -def step_user_expired_token(context): - """Usuario con token expirado.""" - context.is_authenticated = False - context.token_expired = True - - -# ==================== -# WHEN STEPS -# ==================== - -@when('el usuario solicita ver su perfil') -def step_request_profile(context): - """Solicita ver el perfil.""" - profile, status, error = profile_service.get_profile( - context.user_id, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('el usuario actualiza su nombre a "{new_name}"') -def step_update_name(context, new_name): - """Actualiza el nombre del perfil.""" - profile, status, error = profile_service.update_profile( - context.user_id, - name=new_name, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('el usuario intenta cambiar nombre a "{name}"') -def step_try_update_name(context, name): - """Intenta cambiar nombre (puede fallar).""" - step_update_name(context, name) - - -@when('cambia su nombre a "{name}"') -def step_change_name(context, name): - """Cambia nombre (contexto genérico).""" - step_update_name(context, name) - - -@when('intenta cambiar nombre a "{name}" repetido {times} veces') -def step_update_long_name(context, name, times): - """Nombre muy largo.""" - long_name = name * (int(times) + 1) - step_update_name(context, long_name) - - -@when('el usuario sube un nuevo avatar "{avatar_url}"') -def step_update_avatar(context, avatar_url): - """Sube nuevo avatar.""" - profile, status, error = profile_service.update_profile( - context.user_id, - avatar_url=avatar_url, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('intenta cambiar avatar a "{avatar_url}"') -def step_try_update_avatar(context, avatar_url): - """Intenta cambiar avatar.""" - step_update_avatar(context, avatar_url) - - -@when('el usuario cambia idioma a "{language}"') -def step_change_language(context, language): - """Cambia el idioma.""" - profile, status, error = profile_service.update_profile( - context.user_id, - language=language, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('cambia idioma a "{language}"') -def step_change_lang(context, language): - """Alias para cambiar idioma.""" - step_change_language(context, language) - - -@when('intenta cambiar idioma a "{language}"') -def step_try_change_language(context, language): - """Intenta cambiar idioma.""" - step_change_language(context, language) - - -@when('el usuario solo actualiza nombre a "{new_name}"') -def step_update_only_name(context, new_name): - """Actualiza solo el nombre.""" - step_update_name(context, new_name) - - -@when('envía actualización con nombre "{name}", avatar "{avatar}", idioma "{language}"') -def step_update_multiple_fields(context, name, avatar, language): - """Actualiza múltiples campos.""" - profile, status, error = profile_service.update_profile( - context.user_id, - name=name, - avatar_url=avatar, - language=language, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - context.profile = profile - - -@when('intenta actualizar perfil de usuario "{target_user_id}"') -def step_try_update_other_user(context, target_user_id): - """Intenta editar perfil de otro usuario.""" - context.user_id = target_user_id - context.is_owner = False - step_request_profile(context) - - -@when('intenta actualizar su perfil') -def step_try_update_own_profile(context): - """Intenta actualizar su propio perfil.""" - profile, status, error = profile_service.update_profile( - context.user_id, - authenticated=context.is_authenticated, - is_owner=context.is_owner - ) - context.response_status = status - context.response_error = error - - -# ==================== -# THEN STEPS -# ==================== - -@then('el sistema retorna los datos completos del perfil') -def step_return_profile_data(context): - """Verifica que retorna datos del perfil.""" - assert context.profile is not None, "Profile should not be None" - assert context.response_status == 200, f"Expected 200, got {context.response_status}" - - -@then('incluye id "{expected_id}", nombre "{expected_name}"') -def step_profile_contains_id_name(context, expected_id, expected_name): - """Verifica ID y nombre en respuesta.""" - assert context.profile.id == expected_id, f"Expected id {expected_id}, got {context.profile.id}" - assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}" - - -@then('incluye avatar_url y language "{expected_lang}"') -def step_profile_contains_avatar_lang(context, expected_lang): - """Verifica avatar y lenguaje.""" - assert context.profile.avatar_url, "Avatar URL should be present" - assert context.profile.language == expected_lang, f"Expected language {expected_lang}" - - -@then('el sistema retorna error {status_code} "{error_message}"') -def step_return_error(context, status_code, error_message): - """Verifica error específico.""" - status_code = int(status_code) - assert context.response_status == status_code, f"Expected {status_code}, got {context.response_status}" - assert context.response_error == error_message, f"Expected '{error_message}', got '{context.response_error}' - - -@then('el perfil muestra nombre "{expected_name}"') -def step_profile_shows_name(context, expected_name): - """Verifica nombre en perfil.""" - assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}" - - -@then('la fecha de updated_at se actualiza') -def step_updated_at_changed(context): - """Verifica que updated_at cambió (simplificado para test).""" - # En test real verificaríamos timestamp diferente - assert context.profile is not None - - -@then('el sistema acepta el cambio') -def step_accept_change(context): - """Verifica que el cambio fue aceptado.""" - assert context.response_status == 200, f"Expected 200, got {context.response_status}" - - -@then('el nombre se guarda como "{expected_name}"') -def step_name_saved(context, expected_name): - """Verifica nombre guardado.""" - assert context.profile.name == expected_name - - -@then('el sistema muestra error de validación "{error_message}"') -def step_validation_error(context, error_message): - """Verifica error de validación.""" - assert context.response_status == 400, f"Expected 400, got {context.response_status}" - assert context.response_error == error_message or "Nombre inválido" in context.response_error - - -@then('el nombre permanece sin cambios') -def step_name_unchanged(context): - """Verifica que el nombre no cambió.""" - # En tests reales compararíamos con valor original - assert context.profile is not None or context.response_status == 400 - - -@then('el sistema muestra error "{error_message}"') -def step_show_error(context, error_message): - """Verifica mensaje de error genérico.""" - # Acepta cualquier mensaje de error que contenga el texto esperado - assert context.response_error is not None or context.response_status >= 400 - - -@then('el perfil muestra avatar_url "{expected_url}"') -def step_avatar_updated(context, expected_url): - """Verifica nuevo avatar.""" - assert context.profile.avatar_url == expected_url - - -@then('el avatar_url permanece "{expected_url}"') -def step_avatar_unchanged(context, expected_url): - """Verifica que avatar no cambió.""" - assert context.profile.avatar_url == expected_url - - -@then('el idioma se guarda como "{expected_lang}"') -def step_language_saved(context, expected_lang): - """Verifica idioma guardado.""" - assert context.profile.language == expected_lang - - -@then('el sistema confirma el cambio') -def step_confirm_change(context): - """Confirma que el cambio fue exitoso.""" - assert context.response_status == 200 - - -@then('todos los campos se actualizan correctamente') -def step_all_fields_updated(context): - """Verifica actualización múltiple.""" - assert context.response_status == 200 - assert context.profile is not None - - -@then('el perfil refleja todos los cambios') -def step_profile_reflects_changes(context): - """Verifica que todos los cambios están en el perfil.""" - assert context.profile is not None \ No newline at end of file diff --git a/harness/agents.matrix.yml b/harness/agents.matrix.yml index 0131331..9d2c9ec 100644 --- a/harness/agents.matrix.yml +++ b/harness/agents.matrix.yml @@ -4,18 +4,19 @@ roles: leader: emoji: "🧭" can_edit: ["work/", "backlog/", "spec/", "harness/", "AGENTS.md", "CHECKPOINTS.md"] - cannot_edit: ["src/", "tests/"] + cannot_edit: ["project/", "tests/"] responsibilities: - plan - orchestrate - enforce_gates + - publish_ticket_changes - close_feature - issue_orders_in_english_caveman triager: emoji: "🧩" can_edit: ["backlog/", "work/artifacts/", "spec/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status=done"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status=done"] responsibilities: - normalize_requests - create_tickets_in_english_caveman @@ -24,20 +25,21 @@ roles: architect: emoji: "🏗️" can_edit: ["spec/", "harness/contracts/", "docs/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status"] responsibilities: - design - update_contracts implementer: emoji: "🛠️" - can_edit: ["src/", "tests/", "work/artifacts/"] + can_edit: ["project/", "tests/", "work/artifacts/"] cannot_edit: - "backlog/features.json:done" - "work/history.md" - "work/artifacts/*/reviewer.json" - "work/artifacts/*/security.json" - "work/artifacts/*/qa.json" + - "work/artifacts/*/publish.json" - "work/artifacts/*/leader-close.json" responsibilities: - implement_feature @@ -47,7 +49,7 @@ roles: reviewer: emoji: "🔍" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - technical_review - emit_reviewer_verdict @@ -55,7 +57,7 @@ roles: security: emoji: "🔒" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - sast - dependency_review @@ -65,7 +67,7 @@ roles: qa: emoji: "🧪" can_edit: ["work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/"] + cannot_edit: ["project/", "tests/", "backlog/"] responsibilities: - acceptance_traceability - integration_e2e_checks @@ -75,7 +77,7 @@ roles: documenter: emoji: "📚" can_edit: ["docs/", "spec/", "README.md", "HOWTO.md", "work/artifacts/"] - cannot_edit: ["src/", "tests/", "backlog/features.json:status"] + cannot_edit: ["project/", "tests/", "backlog/features.json:status"] responsibilities: - document_feature_changes - update_user_docs diff --git a/harness/workflow.stages.yml b/harness/workflow.stages.yml index 3f90eb6..954b2b2 100644 --- a/harness/workflow.stages.yml +++ b/harness/workflow.stages.yml @@ -63,9 +63,16 @@ stages: - work/artifacts//leader-close.json - work/history.md + - name: publish + owner: leader + required: true + output: + - work/artifacts//publish.json + close_requirements: - reviewer.json.verdict == "APPROVED" - security.json.verdict == "APPROVED" - qa.json.verdict == "APPROVED" - documenter.md exists + - publish.json.verdict == "PUBLISHED" - scripts/verify.sh exit_code == 0 diff --git a/project/README.md b/project/README.md new file mode 100644 index 0000000..b010e45 --- /dev/null +++ b/project/README.md @@ -0,0 +1,11 @@ +# Project code lives here + +Put the real project code inside this directory. + +Examples: +- `project/app.py` +- `project/templates/` +- `project/static/` +- `project/tests/` (optional, if you want local tests here) + +ARNES core stays outside this folder. diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 99b1135..0000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["pytest:."], - "testpaths": ["tests"], - "pythonpath": ["."], - "addopts": "-v" -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2cadbf3..113b0ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,2 @@ -fastapi>=0.100.0 -uvicorn>=0.23.0 -pydantic>=2.0.0 -pytest>=7.0.0 -httpx>=0.24.0 -PyJWT>=2.8.0 -bcrypt>=4.0.0 \ No newline at end of file +# Template core has no hard runtime deps. +# Add project-specific dependencies after running ./scripts/start.sh diff --git a/scripts/agent_status.py b/scripts/agent_status.py index 24586d9..a5a24a0 100755 --- a/scripts/agent_status.py +++ b/scripts/agent_status.py @@ -8,7 +8,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] STATUS_PATH = ROOT / 'work' / 'runtime-status.json' MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml' +WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml' ARTIFACTS_DIR = ROOT / 'work' / 'artifacts' +VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'} DEFAULT_EMOJIS = { 'leader': '🧭', @@ -26,6 +28,7 @@ GATE_FILES = { 'security': 'security.json', 'qa': 'qa.json', 'documenter': 'documenter.md', + 'publish': 'publish.json', 'leader': 'leader-close.json', } @@ -60,6 +63,28 @@ def load_role_emojis(): return emojis +def load_roles(): + roles = [] + if not MATRIX_PATH.exists(): + return roles + for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines(): + match_role = re.match(r'^ ([a-z_]+):\s*$', line) + if match_role: + roles.append(match_role.group(1)) + return roles + + +def load_stage_names(): + stages = [] + if not WORKFLOW_PATH.exists(): + return stages + for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines(): + match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line) + if match_stage: + stages.append(match_stage.group(1)) + return stages + + def default_status(): return { 'feature_id': None, @@ -99,7 +124,8 @@ def gate_status(feature_id): continue try: payload = json.loads(path.read_text(encoding='utf-8')) - gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present' + wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED' + gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present' except Exception: gates[gate] = 'invalid' return gates @@ -115,10 +141,25 @@ def render_gate(gate, state, emojis): label = { 'leader': 'close', 'documenter': 'docs', + 'publish': 'publish', }.get(gate, gate) return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}" +def validate_runtime_args(args): + roles = set(load_roles()) or set(DEFAULT_EMOJIS) + stages = set(load_stage_names()) | {'idle'} + + if args.agent is not None and args.agent not in roles: + raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}") + if args.next_agent is not None and args.next_agent not in roles: + raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}") + if args.stage is not None and args.stage not in stages: + raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}") + if args.state is not None and args.state not in VALID_RUNTIME_STATES: + raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}") + + def show_status(): status = load_status() emojis = load_role_emojis() @@ -141,7 +182,7 @@ def show_status(): print() print('Gates') if gates: - for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']: + for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']: print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}") else: print(' — Sin feature activa —') @@ -162,6 +203,7 @@ def show_status(): def set_status(args): + validate_runtime_args(args) status = load_status() if args.feature_id is not None: status['feature_id'] = args.feature_id or None diff --git a/scripts/install_into_repo.sh b/scripts/install_into_repo.sh new file mode 100755 index 0000000..d173619 --- /dev/null +++ b/scripts/install_into_repo.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_INPUT="${1:-}" + +if [ -z "$TARGET_INPUT" ]; then + echo "Usage: ./scripts/install_into_repo.sh /path/to/target-repo" + exit 1 +fi + +mkdir -p "$TARGET_INPUT" +TARGET_ROOT="$(cd "$TARGET_INPUT" && pwd)" + +if [ "$TARGET_ROOT" = "$SRC_ROOT" ]; then + echo "Refusing to install ARNES into its own source repository. Use a different project repo." + exit 1 +fi + +if ! git -C "$TARGET_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "No git repo detected at target. Initializing git repository..." + git -C "$TARGET_ROOT" init >/dev/null +fi + +copy_item() { + local item="$1" + if [ -d "$SRC_ROOT/$item" ]; then + mkdir -p "$TARGET_ROOT/$item" + cp -R "$SRC_ROOT/$item"/. "$TARGET_ROOT/$item"/ + else + cp "$SRC_ROOT/$item" "$TARGET_ROOT/$item" + fi +} + +ITEMS=( + AGENTS.md + AGENTS.local.md.example + CHECKPOINTS.md + HOWTO.md + HOWTO-FEATURE.md + README.md + TEMPLATE.md + Makefile + requirements.txt + backlog + defaults + docs + features + harness + platforms + project + scripts + spec + starter-pack + work +) + +for item in "${ITEMS[@]}"; do + copy_item "$item" +done + +echo "Installed ARNES core into: $TARGET_ROOT" +echo "Next steps:" +echo " cd $TARGET_ROOT" +echo " ./scripts/start.sh" +echo " ./scripts/verify.sh" diff --git a/scripts/new_ticket.py b/scripts/new_ticket.py index c50d71c..bd3417f 100755 --- a/scripts/new_ticket.py +++ b/scripts/new_ticket.py @@ -5,6 +5,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BACKLOG = ROOT / 'backlog' / 'features.json' +TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore') +LEVEL_CHOICES = ('low', 'med', 'high') def ask(prompt, default=''): @@ -12,10 +14,23 @@ def ask(prompt, default=''): return value if value else default +def ask_choice(prompt, choices, default): + while True: + value = ask(prompt, default).lower() + if value in choices: + return value + print(f"Invalid value. Use one of: {', '.join(choices)}") + + +def ask_list(prompt, default_csv=''): + raw = ask(prompt, default_csv) + return [item.strip() for item in raw.split(',') if item.strip()] + + def next_id(features): nums = [] - for f in features: - fid = str(f.get('id', '')) + for feature in features: + fid = str(feature.get('id', '')) if fid.startswith('F-') and fid[2:].isdigit(): nums.append(int(fid[2:])) return f"F-{(max(nums) + 1) if nums else 1:03d}" @@ -26,14 +41,14 @@ def main(): features = data.get('features', []) print('Create ticket (English caveman style).') - ttype = ask('Type (feature/fix/bug/chore)', 'feature') - title = ask('Title (short EN)', f'{ttype.capitalize()} TODO') + ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature') + title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO') problem = ask('Problem (short EN)', 'Need change') goal = ask('Goal (short EN)', 'Make flow better') - scope_in = ask('Scope IN (comma list EN)', 'Core flow') - scope_out = ask('Scope OUT (comma list EN)', 'No redesign') - risk = ask('Risk (low/med/high)', 'low') - priority = ask('Priority (low/med/high)', 'med') + scope_in = ask_list('Scope IN (comma list EN)', 'Core flow') + scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign') + risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low') + priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med') print('Acceptance bullets (EN caveman). Empty line to end.') acceptance = [] @@ -47,29 +62,38 @@ def main(): acceptance = [ 'Flow works end to end', 'No break old behavior', - 'verify.sh is green' + 'verify.sh is green', ] fid = next_id(features) desc = ( f"Problem: {problem}. " f"Goal: {goal}. " - f"Scope IN: {scope_in}. " - f"Scope OUT: {scope_out}. " - f"Type: {ttype}. Priority: {priority}. Risk: {risk}." + f"Scope IN: {', '.join(scope_in) or 'none'}. " + f"Scope OUT: {', '.join(scope_out) or 'none'}. " + f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}." ) features.append({ 'id': fid, + 'type': ticket_type, 'title': title, + 'problem': problem, + 'goal': goal, + 'scope_in': scope_in, + 'scope_out': scope_out, + 'priority': priority, + 'risk': risk, 'description': desc, 'acceptance': acceptance, 'status': 'pending', 'created_at': str(date.today()), - 'gates': {'review': False, 'security': False, 'qa': False} + 'gates': {'review': False, 'security': False, 'qa': False}, }) data['features'] = features + rules = data.setdefault('rules', {}) + rules.setdefault('valid_types', list(TYPE_CHOICES)) BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') print(f'Created {fid}: {title}') diff --git a/scripts/publish_ticket.py b/scripts/publish_ticket.py new file mode 100755 index 0000000..f662107 --- /dev/null +++ b/scripts/publish_ticket.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BACKLOG = ROOT / 'backlog' / 'features.json' +ARTIFACTS = ROOT / 'work' / 'artifacts' + + +def now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def run_git(*args, check=True): + result = subprocess.run( + ['git', *args], + cwd=ROOT, + text=True, + capture_output=True, + ) + if check and result.returncode != 0: + msg = result.stderr.strip() or result.stdout.strip() or f'git command failed: {args}' + raise SystemExit(msg) + return result + + +def ensure_git_repo(): + result = run_git('rev-parse', '--is-inside-work-tree', check=False) + if result.returncode != 0 or result.stdout.strip() != 'true': + raise SystemExit('This project is not inside a git repository. Run git init or clone a repo first.') + + +def load_backlog(): + return json.loads(BACKLOG.read_text(encoding='utf-8')) + + +def find_feature(feature_id): + data = load_backlog() + for feature in data.get('features', []): + if str(feature.get('id')) == feature_id: + return feature + raise SystemExit(f'Feature not found in backlog: {feature_id}') + + +def current_branch(): + branch = run_git('symbolic-ref', '--quiet', '--short', 'HEAD', check=False).stdout.strip() + if not branch: + branch = run_git('rev-parse', '--abbrev-ref', 'HEAD', check=False).stdout.strip() + if not branch or branch == 'HEAD': + raise SystemExit('Detached HEAD is not supported for publish. Checkout a branch first.') + return branch + + +def ensure_remote(remote): + remotes = [line.strip() for line in run_git('remote').stdout.splitlines() if line.strip()] + if remote not in remotes: + raise SystemExit(f'Remote not found: {remote}. Add it first with git remote add {remote} .') + + +def status_porcelain(): + return run_git('status', '--porcelain').stdout.strip() + + +def default_commit_message(feature): + feature_id = feature['id'] + ticket_type = feature.get('type') + title = str(feature.get('title', '')).strip() + if ticket_type: + return f'{feature_id} {ticket_type}: {title}' + return f'{feature_id}: {title}' + + +def write_publish_artifact(feature_id, payload): + feature_dir = ARTIFACTS / feature_id + feature_dir.mkdir(parents=True, exist_ok=True) + path = feature_dir / 'publish.json' + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + return path + + +def ensure_git_identity(): + name = run_git('config', 'user.name', check=False).stdout.strip() + email = run_git('config', 'user.email', check=False).stdout.strip() + if not name or not email: + raise SystemExit('Missing git identity. Configure git user.name and user.email before publish.') + + +def main(): + parser = argparse.ArgumentParser(description='Commit and push one ticket, then write publish.json evidence.') + parser.add_argument('--feature-id', required=True) + parser.add_argument('--remote', default='origin') + parser.add_argument('--branch', default='') + parser.add_argument('--commit-message', default='') + args = parser.parse_args() + + ensure_git_repo() + ensure_git_identity() + feature = find_feature(args.feature_id) + remote = args.remote.strip() or 'origin' + branch = args.branch.strip() or current_branch() + ensure_remote(remote) + + if not status_porcelain(): + raise SystemExit('No git changes to publish. Nothing to commit.') + + commit_message = args.commit_message.strip() or default_commit_message(feature) + payload = { + 'agent': 'leader', + 'verdict': 'PUBLISHED', + 'feature_id': args.feature_id, + 'branch': branch, + 'remote': remote, + 'message': commit_message, + 'pushed': True, + 'published_at': now_iso(), + 'note': 'This artifact is committed inside the publish commit for this ticket.' + } + artifact_path = write_publish_artifact(args.feature_id, payload) + + run_git('add', '-A') + if not status_porcelain(): + raise SystemExit('No staged git changes after git add -A. Nothing to commit.') + + run_git('commit', '-m', commit_message) + run_git('push', remote, branch) + print(f'done -> {artifact_path}') + + +if __name__ == '__main__': + main() diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index 8ed788d..0000000 --- a/scripts/run.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# Script para arrancar el servidor ARNES UI API - -set -e - -cd "$(dirname "$0")" - -# Configuración -PORT=${1:-8000} -HOST="0.0.0.0" - -# Colores -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN} ARNES API - Starting...${NC}" -echo -e "${GREEN}========================================${NC}" -echo "" -echo -e " URL: ${YELLOW}http://localhost:${PORT}/ui/login.html${NC}" -echo -e " Host: ${YELLOW}${HOST}:${PORT}${NC}" -echo "" -echo -e " Credenciales de prueba:" -echo -e " Email: ${YELLOW}alice@example.com${NC}" -echo -e " Password: ${YELLOW}SecurePass123!${NC}" -echo "" - -# Instalar dependencias si falta -if ! python3 -c "import fastapi" 2>/dev/null; then - echo -e "${YELLOW}Instalando dependencias...${NC}" - pip3 install -q fastapi uvicorn pydantic PyJWT bcrypt httpx -fi - -# Arrancar servidor -exec python3 -m uvicorn src.main:app --host "$HOST" --port "$PORT" --reload \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh index d0b176e..d13b053 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -16,12 +16,16 @@ ask() { } echo "=== ARNES start wizard ===" +echo "Mode: use this template in a new repo or copy core ARNES into an existing repo." -echo "Mode: clone arnes-fork, put your app folder inside, run this wizard." +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "No git repo detected. Initializing local git repository..." + git init >/dev/null +fi PROJECT_NAME="$(ask 'Project name' 'my-project')" PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')" -APP_DIR="$(ask 'App directory (relative)' 'app')" +APP_DIR="$(ask 'App directory (relative)' 'project')" STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')" if [ "$STACK_CHOICE" = "2" ]; then @@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')" ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')" mkdir -p "$APP_DIR" +[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" < harness/project.config.json < work/current.md </dev/null 2>&1; then + ok "Git repo detectado" + if git remote | grep -q .; then + ok "Git remote configurado" + else + warn "Sin git remote configurado (publish requerirá remote)" + fi +else + fail "Este proyecto debe vivir dentro de un git repo" + EXIT_CODE=1 +fi + echo "" echo "── 2) Validando backlog + gates ───────────────────────" python3 - <<'PY' @@ -59,6 +88,7 @@ import sys root = pathlib.Path('.') path = root / 'backlog' / 'features.json' +level_choices = {'low', 'med', 'high'} try: data = json.loads(path.read_text(encoding='utf-8')) @@ -66,7 +96,9 @@ except Exception as e: print(f"[FAIL] backlog/features.json inválido: {e}") sys.exit(1) -valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"])) +rules = data.get('rules', {}) +valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"])) +valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"])) features = data.get('features', []) if not isinstance(features, list): print('[FAIL] features debe ser una lista') @@ -85,25 +117,65 @@ if len(in_progress) > 1: for f in features: fid = str(f.get('id', '')).strip() status = f.get('status') - if status not in valid: + title = str(f.get('title', '')).strip() + acceptance = f.get('acceptance') + gates = f.get('gates', {}) + + if not fid: + print('[FAIL] Hay una feature sin id') + sys.exit(1) + if not title: + print(f"[FAIL] Feature {fid} sin title") + sys.exit(1) + if status not in valid_status: print(f"[FAIL] Estado inválido en feature {fid}: {status}") sys.exit(1) + if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance): + print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía") + sys.exit(1) + + ticket_type = f.get('type') + if ticket_type is not None and ticket_type not in valid_types: + print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}") + sys.exit(1) + + for field in ('priority', 'risk'): + value = f.get(field) + if value is not None and value not in level_choices: + print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}") + sys.exit(1) + + for field in ('scope_in', 'scope_out'): + value = f.get(field) + if value is not None: + if not isinstance(value, list) or any(not str(item).strip() for item in value): + print(f"[FAIL] Feature {fid} tiene {field} inválido") + sys.exit(1) + + if gates: + for gate_name in ('review', 'security', 'qa'): + gate_value = gates.get(gate_name) + if not isinstance(gate_value, bool): + print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido") + sys.exit(1) if status == 'done': d = root / 'work' / 'artifacts' / fid - req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md'] + req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.json'] missing = [name for name in req if not (d / name).is_file()] if missing: print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}") sys.exit(1) expected = { - 'reviewer.json': 'reviewer', - 'security.json': 'security', - 'qa.json': 'qa', - 'leader-close.json': 'leader', + 'reviewer.json': ('reviewer', 'APPROVED'), + 'security.json': ('security', 'APPROVED'), + 'qa.json': ('qa', 'APPROVED'), + 'leader-close.json': ('leader', 'APPROVED'), + 'publish.json': ('leader', 'PUBLISHED'), } - for filename, agent in expected.items(): + for filename, rule in expected.items(): + agent, verdict = rule try: obj = json.loads((d / filename).read_text(encoding='utf-8')) except Exception as e: @@ -113,8 +185,11 @@ for f in features: if obj.get('agent') != agent: print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'") sys.exit(1) - if obj.get('verdict') != 'APPROVED': - print(f"[FAIL] {fid}/{filename} no está APPROVED") + if obj.get('verdict') != verdict: + print(f"[FAIL] {fid}/{filename} no está {verdict}") + sys.exit(1) + if filename == 'publish.json' and obj.get('pushed') is not True: + print(f"[FAIL] {fid}/{filename} debe tener pushed=true") sys.exit(1) print(f"[OK] backlog válido ({len(features)} features)") diff --git a/spec/bdd/README.md b/spec/bdd/README.md index 0e6ce3f..895c87b 100644 --- a/spec/bdd/README.md +++ b/spec/bdd/README.md @@ -10,13 +10,16 @@ ## Overview -Este directorio contiene especificaciones BDD en formato Gherkin. -Los archivos `.feature` sirven como especificación ejecutable. +Este directorio contiene las especificaciones BDD fuente en formato Gherkin. + +Separación recomendada: +- `spec/bdd/features/` = source-of-truth de escenarios +- `features/` = assets ejecutables del runner (steps, config) ### naming conventions -``` -features/ +```text +spec/bdd/features/ ├── / │ ├── .feature │ └── .feature diff --git a/spec/bdd/features/.gitkeep b/spec/bdd/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/bdd/features/README.md b/spec/bdd/features/README.md index 2d09975..16f2ef1 100644 --- a/spec/bdd/features/README.md +++ b/spec/bdd/features/README.md @@ -1,58 +1,12 @@ -# Features BDD +# BDD feature files -Este directorio contiene los archivos `.feature` organizados por dominio. +Put Gherkin `.feature` files here. -## Estructura +Example: +- `spec/bdd/features/checkout/purchase.feature` +- `spec/bdd/features/common/error-handling.feature` -``` -features/ -├── auth/ -│ ├── login.feature -│ └── registration.feature -├── dashboard/ -│ └── dashboard.feature -├── common/ -│ ├── navigation.feature -│ └── error-handling.feature -└── README.md -``` - -## Tags comunes - -Usar estos tags en todos los features: - -| Tag | Descripción | -|-----|-------------| -| `@F-XXX` | Link a feature ID del backlog | -| `@smoke` | Test crítico | -| `@regression` | Regresión | - -## Example - -```gherkin -@F-001 @auth @smoke -Feature: Inicio de sesión - - Como usuario registrado - Quiero iniciar sesión con mis credenciales - Para acceder a mi cuenta personal - - @positive - Scenario: Login exitoso con credenciales válidas - Given un usuario con email "user@example.com" y password "Password123" - And el usuario no tiene sesión activa - When el usuario ingresa email "user@example.com" - And ingresa password "Password123" - And presiona el botón "Iniciar sesión" - Then el sistema redirige al dashboard - And muestra mensaje de bienvenida - - @negative - Scenario: Login fallido con password incorrecto - Given un usuario con email "user@example.com" y password "Password123" - When el usuario ingresa email "user@example.com" - And ingresa password "WrongPassword" - And presiona el botón "Iniciar sesión" - Then el sistema muestra mensaje de error "Credenciales inválidas" - And permanece en la página de login -``` \ No newline at end of file +Use tags like: +- `@F-001` +- `@smoke` +- `@regression` diff --git a/spec/bdd/features/auth/login.feature b/spec/bdd/features/auth/login.feature deleted file mode 100644 index ff45d47..0000000 --- a/spec/bdd/features/auth/login.feature +++ /dev/null @@ -1,70 +0,0 @@ -@F-004 @auth @login -Feature: User Login - - Background: - Given the user "alice@example.com" exists with password "SecurePass123!" - - @positive - Scenario: Successful login with valid credentials - Given I have valid email "alice@example.com" and password "SecurePass123!" - When I attempt to login - Then I should receive an access token - And the access token should contain user_id claim - And the access token should contain email claim - And the access token should not be expired - - @positive - Scenario: Login returns refresh token - Given I have valid credentials for "alice@example.com" - When I login successfully - Then I should receive a refresh token - And the refresh token should be different from access token - And the refresh token should have longer expiration - - @positive - Scenario: Login email is case-insensitive - Given a user exists with email "bob@test.com" and password "TestPass99!" - When I login with email "BOB@TEST.COM" and password "TestPass99!" - Then login should be successful - - @negative - Scenario: Login with wrong password - Given I have email "alice@example.com" and password "WrongPassword123!" - When I attempt to login - Then I should receive error "Credenciales inválidas" - And I should not receive any token - - @negative - Scenario: Login with nonexistent user - Given I have email "nonexistent@test.com" and password "AnyPass123!" - When I attempt to login - Then I should receive error "Credenciales inválidas" - And I should not receive any token - - @negative - Scenario: Login with empty password - Given I have email "alice@example.com" and empty password - When I attempt to login - Then I should receive validation error - And I should not receive any token - - @negative - Scenario: Login with invalid email format - Given I have email "not-an-email" and password "ValidPass123!" - When I attempt to login - Then I should receive validation error - And I should not receive any token - - @security @rate-limit - Scenario: Login blocked after 10 failed attempts - Given I have email "alice@example.com" and password "WrongPassword!" - When I attempt to login 10 times with wrong password - Then account should be temporarily locked - And next login attempt should return error "Cuenta bloqueada" - - @smoke - Scenario: Login endpoint responds with JSON - Given I have valid credentials for "alice@example.com" - When I send a POST request to "/api/v1/auth/login" - Then response should be JSON format - And response should have correct content-type header \ No newline at end of file diff --git a/spec/bdd/features/auth/logout.feature b/spec/bdd/features/auth/logout.feature deleted file mode 100644 index 70977db..0000000 --- a/spec/bdd/features/auth/logout.feature +++ /dev/null @@ -1,58 +0,0 @@ -@F-004 @auth @logout -Feature: User Logout - - Background: - Given the user "alice@example.com" exists with password "SecurePass123!" - And I am authenticated as "alice@example.com" - - @positive - Scenario: Successful logout invalidates current session - Given my current access token is valid - When I logout - Then I should receive confirmation - And my session should be marked as revoked - And my access token should no longer be valid - - @positive - Scenario: Logout with refresh token also invalidates access - Given I have a valid refresh token - When I logout - Then both access and refresh tokens should be invalid - And I should not be able to get new access token with refresh - - @positive - Scenario: Logout all sessions for user - Given I am logged in from device "desktop" - And I am logged in from device "mobile" - When I logout from all devices - Then all my sessions should be invalidated - And I should not be able to use any previous token - - @negative - Scenario: Using token after logout returns unauthorized - Given I previously logged in successfully - And I have logged out - When I try to use my old access token - Then I should receive 401 Unauthorized - And I should not have access to protected resources - - @negative - Scenario: Logout with invalid token does nothing - Given I have an invalid/expired token - When I attempt to logout - Then logout should not fail - But no session should be affected - - @security - Scenario: Concurrent logout requests are handled correctly - Given my session is valid - When I send multiple logout requests simultaneously - Then only one logout operation should occur - And token should be invalidated only once - - @smoke - Scenario: Logout endpoint returns 200 on success - Given I am authenticated as "alice@example.com" - When I send POST request to "/api/v1/auth/logout" - Then response should be 200 OK - And response should indicate success \ No newline at end of file diff --git a/spec/bdd/features/common/README.md b/spec/bdd/features/common/README.md deleted file mode 100644 index c2fad55..0000000 --- a/spec/bdd/features/common/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Common Features - -Features que se reutilizan en múltiples dominios. - -## Navigation - -```gherkin -@common @navigation -Feature: Navegación entre páginas - - Scenario: Navegar a través del menú - Given el usuario está en la página principal - When hace clic en el elemento de menú "Dashboard" - Then la URL cambia a "/dashboard" - And el título de la página muestra "Dashboard" -``` - -## Error Handling - -```gherkin -@common @error-handling -Feature: Manejo de errores - - Scenario: Mostrar error de red - Given la conexión a internet está disponible - And el servidor no responde - When el usuario realiza una acción que requiere red - Then el sistema muestra toast "Error de conexión" - And ofrece opción de reintentar - - Scenario: Timeout de solicitud - Given el usuario tiene sesión activa - When realiza una solicitud que excede 30 segundos - Then el sistema muestra indicador de carga - And después de timeout muestra error "Solicitud expirada" -``` \ No newline at end of file diff --git a/spec/bdd/features/password/change-password.feature b/spec/bdd/features/password/change-password.feature deleted file mode 100644 index b2a68b7..0000000 --- a/spec/bdd/features/password/change-password.feature +++ /dev/null @@ -1,171 +0,0 @@ -@F-003 @password -Feature: Cambio de Contraseña - - Como usuario autenticado - Quiero cambiar mi contraseña - Para mantener mi cuenta segura con credenciales actualizadas - - # ==================== - # HAPPY PATH - # ==================== - - @smoke @positive - Scenario: Cambiar contraseña exitosamente - Given un usuario autenticado con email "user@example.com" - And su contraseña actual es "OldPass123!" - When el usuario solicita cambiar contraseña - And ingresa contraseña actual "OldPass123!" - And ingresa nueva contraseña "NewPass456@" - And confirma nueva contraseña "NewPass456@" - Then el sistema valida la contraseña actual correctamente - And guarda la nueva contraseña hasheada - And invalida todas las sesiones existentes - And muestra mensaje de confirmación "Contraseña actualizada exitosamente" - - @positive - Scenario: Contraseña con todos los caracteres especiales permitidos - Given un usuario autenticado - When cambia contraseña a "!@#$%^&*()_+-=[]{}|;':\",./<>?abc123ABC" - Then el sistema acepta la contraseña - And la guarda correctamente - - # ==================== - # PASSWORD VALIDATION - # ==================== - - @negative - Scenario: Nueva contraseña muy corta (menos de 8 caracteres) - Given un usuario autenticado - When intenta cambiar contraseña a "Ab1!" - Then el sistema muestra error "La contraseña debe tener al menos 8 caracteres" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña muy larga (más de 128 caracteres) - Given un usuario autenticado - When intenta cambiar contraseña a "A" repetido 129 veces más "a1!" - Then el sistema muestra error "La contraseña debe tener máximo 128 caracteres" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin mayúscula - Given un usuario autenticado - When intenta cambiar contraseña a "password123!" - Then el sistema muestra error "La contraseña debe contener al menos una mayúscula" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin minúscula - Given un usuario autenticado - When intenta cambiar contraseña a "PASSWORD123!" - Then el sistema muestra error "La contraseña debe contener al menos una minúscula" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin número - Given un usuario autenticado - When intenta cambiar contraseña a "PasswordABC!" - Then el sistema muestra error "La contraseña debe contener al menos un número" - And la contraseña no es cambiada - - @negative - Scenario: Nueva contraseña sin carácter especial - Given un usuario autenticado - When intenta cambiar contraseña a "Password123" - Then el sistema muestra error "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - And la contraseña no es cambiada - - # ==================== - # CURRENT PASSWORD - # ==================== - - @negative - Scenario: Contraseña actual incorrecta - Given un usuario autenticado con contraseña actual "CorrectPass123!" - When intenta cambiar contraseña con actual "WrongPass456!" - And nueva contraseña "NewPass789@" - Then el sistema muestra error "La contraseña actual es incorrecta" - And la contraseña no es cambiada - And no se invalidan sesiones - - @negative - Scenario: Contraseña actual vacía - Given un usuario autenticado - When intenta cambiar contraseña con actual "" - And nueva contraseña "NewPass123@" - Then el sistema muestra error "La contraseña actual es requerida" - And la contraseña no es cambiada - - # ==================== - # PASSWORD MISMATCH - # ==================== - - @negative - Scenario: Nueva contraseña y confirmación no coinciden - Given un usuario autenticado - When ingresa contraseña actual correcta - And ingresa nueva contraseña "NewPass123@" - But confirma con "DifferentPass456!" - Then el sistema muestra error "Las contraseñas no coinciden" - And la contraseña no es cambiada - - # ==================== - # REUSE DETECTION - # ==================== - - @negative @security - Scenario: Reutilizar contraseña anterior - Given un usuario autenticado con contraseña actual "MyPass123!" - And historial de contraseñas incluye "MyPass123!" - When intenta cambiar contraseña a "MyPass123!" - Then el sistema muestra error "La nueva contraseña no puede ser igual a la anterior" - And la contraseña no es cambiada - - # ==================== - # AUTHORIZATION - # ==================== - - @negative @security - Scenario: Usuario no autenticado intenta cambiar contraseña - Given un usuario no autenticado - When intenta cambiar contraseña - Then el sistema retorna error 401 "No autorizado" - And la contraseña no es cambiada - - @negative @security - Scenario: Token expirado al cambiar contraseña - Given un usuario con sesión expirada - When intenta cambiar contraseña - Then el sistema retorna error 401 "Sesión expirada" - And la contraseña no es cambiada - - @negative @security - Scenario: Intentar cambiar contraseña de otro usuario - Given un usuario autenticado con ID "user-123" - When intenta cambiar contraseña del usuario "user-456" - Then el sistema retorna error 403 "No tienes permiso para modificar esta cuenta" - And la contraseña no es cambiada - - # ==================== - # RATE LIMITING - # ==================== - - @negative @security - Scenario: Superar límite de intentos (rate limit) - Given un usuario autenticado - And ya realizó 5 intentos fallidos en la última hora - When intenta cambiar contraseña una vez más - Then el sistema retorna error 429 "Demasiados intentos. Intenta de nuevo en 1 hora" - And todas las solicitudes son bloqueadas hasta que pase el tiempo - - # ==================== - # SUCCESSFUL REAUTHENTICATION - # ==================== - - @positive - Scenario: Cambio de contraseña seguido de login exitoso - Given un usuario con contraseña "OldPass123!" - When cambia su contraseña a "NewPass456@" - And luego intenta iniciar sesión con "NewPass456@" - Then el login es exitoso - And el usuario accede a su cuenta \ No newline at end of file diff --git a/spec/bdd/features/profile/user-profile.feature b/spec/bdd/features/profile/user-profile.feature deleted file mode 100644 index 9fea373..0000000 --- a/spec/bdd/features/profile/user-profile.feature +++ /dev/null @@ -1,159 +0,0 @@ -@F-002 @profile -Feature: Gestión de Perfil de Usuario - - Como usuario autenticado - Quiero gestionar mi perfil - Para mantener mis datos personales actualizados y personalizar mi experiencia - - # ==================== - # VIEW PROFILE - # ==================== - - @smoke @positive - Scenario: Ver perfil de usuario exitosamente - Given un usuario autenticado con ID "user-123" y nombre "Juan Pérez" - And el usuario tiene avatar "https://cdn.example.com/avatar-123.jpg" - And el idioma configurado es "es" - When el usuario solicita ver su perfil - Then el sistema retorna los datos completos del perfil - And incluye id "user-123", nombre "Juan Pérez" - And incluye avatar_url y language "es" - - @negative - Scenario: Ver perfil sin autenticación - Given un usuario no autenticado - When el usuario solicita ver su perfil - Then el sistema retorna error 401 "No autorizado" - And no retorna datos del perfil - - @negative - Scenario: Ver perfil de usuario inexistente - Given un usuario autenticado - When solicita ver perfil de ID "nonexistent-user" - Then el sistema retorna error 404 "Usuario no encontrado" - - # ==================== - # UPDATE NAME - # ==================== - - @smoke @positive - Scenario: Editar nombre del perfil exitosamente - Given un usuario autenticado con ID "user-123" - And el perfil tiene nombre "Juan" - When el usuario actualiza su nombre a "Pedro" - Then el perfil muestra nombre "Pedro" - And la fecha de updated_at se actualiza - - @positive - Scenario: Editar nombre con caracteres unicode válidos - Given un usuario autenticado - When cambia su nombre a "José García" - Then el sistema acepta el cambio - And el nombre se guarda como "José García" - - @negative - Scenario: Editar nombre con caracteres inválidos - Given un usuario autenticado - When intenta cambiar nombre a "Juan@123!" - Then el sistema muestra error de validación "Nombre inválido: solo letras y espacios" - And el nombre permanece sin cambios - - @negative - Scenario: Editar nombre con menos de 2 caracteres - Given un usuario autenticado - When intenta cambiar nombre a "J" - Then el sistema muestra error "Nombre debe tener al menos 2 caracteres" - - @negative - Scenario: Editar nombre con más de 50 caracteres - Given un usuario autenticado - When intenta cambiar nombre a "A" repetido 51 veces - Then el sistema muestra error "Nombre debe tener máximo 50 caracteres" - - # ==================== - # UPDATE AVATAR - # ==================== - - @smoke @positive - Scenario: Cambiar avatar exitosamente - Given un usuario autenticado con avatar actual "https://cdn.example.com/old.jpg" - When el usuario sube un nuevo avatar "https://cdn.example.com/new.jpg" - Then el perfil muestra avatar_url "https://cdn.example.com/new.jpg" - - @negative - Scenario: Cambiar avatar con URL inválida - Given un usuario autenticado - When intenta cambiar avatar a "not-a-valid-url" - Then el sistema muestra error "URL de avatar inválida" - And el avatar permanece sin cambios - - @negative - Scenario: Cambiar avatar con URL de protocolo no permitido - Given un usuario autenticado - When intenta cambiar avatar a "ftp://malicious.com/file.jpg" - Then el sistema muestra error "Solo se permiten URLs http o https" - And el avatar permanece sin cambios - - # ==================== - # UPDATE LANGUAGE - # ==================== - - @smoke @positive - Scenario: Cambiar idioma a español exitosamente - Given un usuario autenticado con idioma "en" - When el usuario cambia idioma a "es" - Then el idioma se guarda como "es" - And el sistema confirma el cambio - - @positive - Scenario: Cambiar idioma a francés - Given un usuario autenticado - When cambia idioma a "fr" - Then el sistema acepta "fr" como idioma válido - - @positive - Scenario: Cambiar idioma a alemán - Given un usuario autenticado - When cambia idioma a "de" - Then el sistema acepta "de" como idioma válido - - @negative - Scenario: Cambiar idioma a idioma no soportado - Given un usuario autenticado - When intenta cambiar idioma a "zh" - Then el sistema muestra error "Idioma no soportado" - And el idioma permanece sin cambios - - # ==================== - # PARTIAL UPDATE - # ==================== - - @positive - Scenario: Actualizar solo nombre sin cambiar avatar - Given un usuario autenticado con nombre "Juan" y avatar "https://cdn.com/img.jpg" - When el usuario solo actualiza nombre a "Pedro" - Then el nombre cambia a "Pedro" - And el avatar_url permanece "https://cdn.com/img.jpg" - - @positive - Scenario: Actualizar múltiples campos en una petición - Given un usuario autenticado - When envía actualización con nombre "María", avatar "https://cdn.com/maria.jpg", idioma "es" - Then todos los campos se actualizan correctamente - And el perfil refleja todos los cambios - - # ==================== - # AUTHORIZATION - # ==================== - - @negative @security - Scenario: Usuario intenta editar perfil de otro usuario - Given un usuario autenticado con ID "user-123" - When intenta actualizar perfil de usuario "user-456" - Then el sistema retorna error 403 "No tienes permiso para editar este perfil" - - @negative @security - Scenario: Token expirado al editar perfil - Given un usuario con token expirado - When intenta actualizar su perfil - Then el sistema retorna error 401 "Sesión expirada" \ No newline at end of file diff --git a/spec/sdd-bdd-guide.md b/spec/sdd-bdd-guide.md index fcfed12..f00010b 100644 --- a/spec/sdd-bdd-guide.md +++ b/spec/sdd-bdd-guide.md @@ -1,4 +1,4 @@ -# SDD/BBD Guide — System Design Document & Behavior Driven Development +# SDD/BDD Guide — System Design Document & Behavior Driven Development Guía para crear y mantener SDD (System Design Document) y BDD (Behavior Driven Development) specs dentro del framework ARNES. @@ -25,10 +25,13 @@ spec/ │ ├── architecture.md │ ├── components/ │ └── decisions/ -└── bdd/ # Behavior Driven Development +└── bdd/ # Behavior Driven Development source-of-truth ├── README.md - ├── features/ - └── step_definitions/ + └── features/ + +features/ # optional executable BDD runner assets +├── behave.ini +└── steps/ ``` --- @@ -152,6 +155,11 @@ spec/bdd/features/ │ └── purchase.feature └── common/ └── error-handling.feature + +features/ +├── behave.ini +└── steps/ + └── login_steps.py ``` ### Tags para trazabilidad @@ -224,8 +232,10 @@ Tags disponibles: ```bash # Estructura +spec/bdd/features/ +└── login.feature + features/ -├── login.feature └── steps/ └── login_steps.py @@ -237,8 +247,10 @@ behave features/ ```bash # Estructura +spec/bdd/features/ +└── login.feature + features/ -├── login.feature └── step_definitions/ └── login_steps.js diff --git a/spec/sdd/components/.gitkeep b/spec/sdd/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/sdd/components/.template.md b/spec/sdd/components/.template.md deleted file mode 100644 index 28f0370..0000000 --- a/spec/sdd/components/.template.md +++ /dev/null @@ -1,74 +0,0 @@ -# Component: - -## Responsabilidad -Descripción clara de qué hace este componente. - -## Tipo -- [ ] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API (si aplica) -``` -Method: GET/POST/PUT/DELETE /endpoint -Input: { ... } -Output: { ... } -Errors: 400, 401, 404, 500 -``` - -### Eventos (si aplica) -- `topic.name.v1` — descripción del evento - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| | | | - -## Límites - -### Alcance -- ✅ Qué hace -- ❌ Qué NO hace - -### Constraints -- Timeout máximo: Xms -- Rate limit: Y req/min - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 | < 200ms | - -## Diagrama - -```mermaid -graph LR - A[Input] --> B[Component] - B --> C[Output] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | created | ... | -| Active | running | ... | -| Error | failure | ... | - -## Seguridad - -- Authentication: ... -- Authorization: ... -- Rate limiting: ... - -## Observabilidad - -- Metrics: ... -- Logs: ... -- Traces: ... \ No newline at end of file diff --git a/spec/sdd/components/README.md b/spec/sdd/components/README.md new file mode 100644 index 0000000..2170c2e --- /dev/null +++ b/spec/sdd/components/README.md @@ -0,0 +1,8 @@ +# SDD components + +Put one markdown file per technical component. + +Example: +- `api-gateway.md` +- `order-service.md` +- `cart-repository.md` diff --git a/spec/sdd/components/auth-service.md b/spec/sdd/components/auth-service.md deleted file mode 100644 index 0437b4e..0000000 --- a/spec/sdd/components/auth-service.md +++ /dev/null @@ -1,65 +0,0 @@ -# AuthService Component - -## Purpose -Handle user authentication (login/logout) with JWT tokens. - -## Public API - -### Methods - -#### login(email: str, password: str) -> AuthResult -Authenticate user with email and password. - -**Parameters:** -- `email`: User email address -- `password`: User password - -**Returns:** -- `AuthResult` with access_token, refresh_token, expires_in - -**Raises:** -- `InvalidCredentialsError`: Email or password incorrect -- `AccountLockedError`: Account temporarily locked -- `ValidationError`: Invalid input format - -#### logout(user_id: str, token_id: str) -> bool -Invalidate a specific session/token. - -**Parameters:** -- `user_id`: User ID -- `token_id`: JWT jti (token identifier) - -**Returns:** True if successful - -#### logout_all(user_id: str) -> int -Invalidate all sessions for a user. - -**Parameters:** -- `user_id`: User ID - -**Returns:** Number of sessions invalidated - -#### refresh(refresh_token: str) -> AuthResult -Get new access token from refresh token. - -**Parameters:** -- `refresh_token`: Valid refresh token - -**Returns:** New AuthResult with access_token - -**Raises:** -- `InvalidTokenError`: Token expired or invalid - ---- - -## Dependencies -- `TokenService`: JWT generation/validation -- `SessionStore`: Track active sessions -- `UserRepository`: Fetch user data -- `PasswordService`: Verify password (from F-003) - -## Configuration -```python -LOGIN_RATE_LIMIT = 10 # attempts per window -RATE_LIMIT_WINDOW = 900 # 15 minutes -ACCOUNT_LOCKOUT_DURATION = 1800 # 30 minutes \ No newline at end of file diff --git a/spec/sdd/components/password-service.md b/spec/sdd/components/password-service.md deleted file mode 100644 index fa4a700..0000000 --- a/spec/sdd/components/password-service.md +++ /dev/null @@ -1,114 +0,0 @@ -# Component: PasswordService - -## Responsabilidad -Gestionar el cambio de contraseña de usuarios autenticados. Validar contraseña actual, verificar requisitos de seguridad de la nueva contraseña, y invalidar sesiones existentes. - -## Tipo -- [x] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API REST - -``` -POST /api/v1/users/{user_id}/change-password -Authorization: Bearer -Input: { - "current_password": string, - "new_password": string, - "confirm_password": string -} -Output: { "success": true, "message": "Contraseña actualizada" } -Errors: - - 400: Validation errors (password too weak, mismatch) - - 401: Current password incorrect - - 403: Not owner - - 404: User not found -``` - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| PostgreSQL | Database | Almacenamiento de usuarios | -| Redis | Cache | Invalidation de sesiones | -| AuthService | Internal | Verificación de token | - -## Validaciones de contraseña - -| Regla | Requisito | Mensaje de error | -|-------|-----------|------------------| -| Longitud mínima | 8 caracteres | "La contraseña debe tener al menos 8 caracteres" | -| Longitud máxima | 128 caracteres | "La contraseña debe tener máximo 128 caracteres" | -| Mayúsculas | Al menos 1 | "La contraseña debe contener al menos una mayúscula" | -| Minúsculas | Al menos 1 | "La contraseña debe contener al menos una minúscula" | -| Números | Al menos 1 | "La contraseña debe contener al menos un número" | -| Caracteres especiales | Al menos 1 | "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" | -| No usar password anterior | Diferente | "La nueva contraseña no puede ser igual a la anterior" | - -## Límites - -### Alcance -- ✅ Cambio de contraseña con validación -- ✅ Requisitos de seguridad -- ✅ Invalidación de sesiones -- ❌ NO maneja recuperación de contraseña (ver ForgotPasswordService) -- ❌ NO maneja reset forzado por admin (ver AdminService) - -### Constraints -- Rate limit: 5 intentos por hora por usuario -- Timeout máximo: 1 segundo -- Máximo 3 passwords válidas en historial (evitar reutilización inmediata) - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 change_password | < 500ms | -| Rate limit | blocked attempts | 100% | -| Sesiones invalidées | después de cambio | 100% | - -## Diagrama - -```mermaid -graph LR - A[Client] -->|POST /change-password| B[PasswordService] - B -->|validate current| C[(PostgreSQL)] - B -->|validate new| D[PasswordValidator] - D -->|strong enough?| E{Valid} - E -->|yes| F[Hash + Save] - E -->|no| G[Return error] - F -->|invalidate| H[(Redis)] - H -->|remove sessions| I[All user tokens] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | started | Connect to DB | -| Ready | db_connected | Accept requests | -| RateLimited | >5 attempts/hour | Return 429 | -| Error | db_failure | Return 503 | - -## Seguridad - -- **Password hashing**: bcrypt, cost 12 (nuevo), verificar contra hash existente -- **Timing attack prevention**: usar constant-time comparison -- **Rate limiting**: 5 req/hour por user_id -- **Sesiones**: invalidar TODAS las sesiones del usuario tras cambio -- **Logs**: NO registrar passwords, solo intentos fallidos (user_id anonymized) - -## Observabilidad - -- Metrics: `password_change_total`, `password_change_failed`, `password_change_latency` -- Logs: structured JSON con request_id -- Traces: OpenTelemetry span por request - -## Tests BDD - -- Ver `spec/bdd/features/password/change-password.feature` \ No newline at end of file diff --git a/spec/sdd/components/session-store.md b/spec/sdd/components/session-store.md deleted file mode 100644 index a6f387f..0000000 --- a/spec/sdd/components/session-store.md +++ /dev/null @@ -1,75 +0,0 @@ -# SessionStore Component - -## Purpose -Manage active user sessions in Redis for fast authentication and revocation. - -## Public API - -### Methods - -#### create_session(user_id: str, token_id: str, metadata: dict) -> bool -Store a new active session. - -**Parameters:** -- `user_id`: User identifier -- `token_id`: JWT jti (unique token ID) -- `metadata`: Optional data (IP, user agent, device) - -**Returns:** True if created - -#### get_session(token_id: str) -> Session | None -Retrieve active session info. - -**Parameters:** -- `token_id`: JWT jti - -**Returns:** Session object or None if expired/revoked - -#### revoke_session(token_id: str) -> bool -Invalidate a specific session. - -**Parameters:** -- `token_id`: JWT jti - -**Returns:** True if revoked - -#### revoke_all_user_sessions(user_id: str) -> int -Invalidate all sessions for a user. - -**Parameters:** -- `user_id`: User identifier - -**Returns:** Count of sessions revoked - -#### get_user_session_count(user_id: str) -> int -Count active sessions for a user. - -**Parameters:** -- `user_id`: User identifier - -**Returns:** Number of active sessions - ---- - -## Redis Keys Structure - -``` -session:{user_id}:{token_id} -> JSON session metadata -user_sessions:{user_id} -> SET of active token_ids -rate_limit:login:{ip} -> COUNT with TTL -``` - -## TTL -- Session tokens: 15 minutes (synced with access token) -- Rate limit counters: 15 minutes - -## Dependencies -- Redis connection (via aioredis) -- TokenService (for token ID generation) - -## Configuration -```python -SESSION_TTL = 900 # 15 minutes -MAX_SESSIONS_PER_USER = 10 -RATE_LIMIT_WINDOW = 900 # 15 minutes -``` \ No newline at end of file diff --git a/spec/sdd/components/token-service.md b/spec/sdd/components/token-service.md deleted file mode 100644 index 84993b7..0000000 --- a/spec/sdd/components/token-service.md +++ /dev/null @@ -1,69 +0,0 @@ -# TokenService Component - -## Purpose -Generate, validate, and manage JWT tokens. - -## Public API - -### Methods - -#### create_access_token(user: User) -> str -Generate a new JWT access token. - -**Parameters:** -- `user`: User object with id, email, role - -**Returns:** JWT token string - -**Token claims:** -```json -{ - "sub": user.id, - "email": user.email, - "role": user.role, - "iat": current_timestamp, - "exp": current_timestamp + 900, # 15 min - "jti": uuid4() -} -``` - -#### create_refresh_token(user: User) -> str -Generate a new refresh token. - -**Returns:** JWT refresh token (7 day expiration) - -#### verify_token(token: str) -> TokenPayload -Validate and decode a JWT token. - -**Parameters:** -- `token`: JWT token string - -**Returns:** TokenPayload with claims - -**Raises:** -- `ExpiredSignatureError`: Token expired -- `InvalidTokenError`: Token invalid/malformed - -#### revoke_token(token_id: str, user_id: str) -> bool -Mark a token as revoked in session store. - -**Parameters:** -- `token_id`: JWT jti claim -- `user_id`: User ID - -**Returns:** True if revoked - ---- - -## Configuration -```python -ACCESS_TOKEN_EXPIRE = 900 # 15 minutes -REFRESH_TOKEN_EXPIRE = 604800 # 7 days -ALGORITHM = "HS256" # or RS256 with key pair -SECRET_KEY = os.getenv("JWT_SECRET") -``` - -## Security -- Tokens include unique `jti` claim for revocation tracking -- Short access token duration minimizes theft window -- Refresh tokens stored in Redis for fast revocation \ No newline at end of file diff --git a/spec/sdd/components/user-profile-service.md b/spec/sdd/components/user-profile-service.md deleted file mode 100644 index fb72c66..0000000 --- a/spec/sdd/components/user-profile-service.md +++ /dev/null @@ -1,111 +0,0 @@ -# Component: UserProfileService - -## Responsabilidad -Gestionar el perfil de usuario: consulta, actualización de datos básicos (nombre, avatar) y preferencias (idioma). - -## Tipo -- [x] Microservicio -- [ ] Library/Biblioteca -- [ ] Shared Component -- [ ] External Integration - -## Interfaces - -### API REST - -``` -GET /api/v1/users/{user_id}/profile -Authorization: Bearer -Output: { - "id": string, - "name": string, - "avatar_url": string, - "language": "en" | "es" | "fr" | "de", - "created_at": ISO8601, - "updated_at": ISO8601 -} -Errors: 401 (unauthorized), 404 (user not found) - -PUT /api/v1/users/{user_id}/profile -Authorization: Bearer -Input: { - "name": string (optional), - "avatar_url": string (optional), - "language": string (optional) -} -Output: { perfil actualizado } -Errors: 400 (validation), 401, 403 (not owner), 404 -``` - -### Eventos (si aplica) -- `profile.updated.v1` — publicado cuando perfil se actualiza - -## Dependencias - -| Servicio/Biblioteca | Tipo | Notas | -|---------------------|------|-------| -| PostgreSQL | Database | Datos de usuarios y perfiles | -| Redis | Cache | Cache de perfil (TTL 5min) | -| Storage Service | External | Almacenamiento de avatares | - -## Límites - -### Alcance -- ✅ CRUD de perfil de usuario -- ✅ Cambio de idioma -- ❌ NO maneja autenticación (AuthService) -- ❌ NO maneja permisos de otros usuarios - -### Constraints -- Timeout máximo: 300ms -- Rate limit: 50 req/min por usuario -- name: 2-50 caracteres, solo letras y espacios -- avatar_url: max 500 caracteres, URL válida (http/https) -- language: uno de ['en', 'es', 'fr', 'de'] - -## Criterios de éxito - -| Criterio | Métrica | Target | -|----------|---------|--------| -| Disponibilidad | uptime | 99.9% | -| Latencia | p99 get_profile | < 100ms | -| Latencia | p99 update_profile | < 200ms | -| Cache hit rate | | > 80% | - -## Diagrama - -```mermaid -graph LR - A[Client] -->|GET /profile| B[UserProfileService] - B -->|cache| C[(Redis)] - B -->|fetch| D[(PostgreSQL)] - - E[Client] -->|PUT /profile| B - B -->|validate| F[Storage] -``` - -## Estados - -| Estado | Trigger | Acción | -|--------|---------|--------| -| Initial | started | Connect to DB, Redis | -| Ready | all_connected | Accept requests | -| Degraded | redis_down | Fallback to DB-only | -| Error | db_failure | Return 503 + alert | - -## Seguridad - -- Authentication: JWT Bearer token required -- Authorization: Solo el dueño puede modificar su perfil -- Input validation: Pydantic, sanitización XSS -- Rate limiting: 50 req/min por user_id - -## Observabilidad - -- Metrics: `profile_get_total`, `profile_update_total`, `profile_latency_ms` -- Logs: structured JSON con user_id (masked) -- Traces: OpenTelemetry span por request - -## Tests BDD - -- Ver `spec/bdd/features/profile/user-profile.feature` \ No newline at end of file diff --git a/spec/sdd/decisions/.gitkeep b/spec/sdd/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/sdd/decisions/.template.md b/spec/sdd/decisions/.template.md deleted file mode 100644 index 07df70b..0000000 --- a/spec/sdd/decisions/.template.md +++ /dev/null @@ -1,48 +0,0 @@ -# ADR-XXX: Título de la Decisión - -## Estado -Aceptado | Propuesto | Deprecado - -## Fecha -YYYY-MM-DD - -## Contexto -_Descripción del problema o situación que motiva esta decisión._ - -## Decisión -_Qué se decidió y por qué._ - -## Justificación -_Razones que fundamentan la decisión._ - -## Consecuencias - -### ✅ Positivas -- ... - -### ❌ Negativas -- ... - -### 🔄 Neutrales -- ... - -## Alternativas Consideradas - -### Opción A -- **Descripción**: ... -- **Pros**: ... -- **Contras**: ... -- **Razón de descarte**: ... - -### Opción B -- **Descripción**: ... -- **Pros**: ... -- **Contras**: ... -- **Razón de descarte**: ... - -## Notas -_Información adicional o follow-ups._ - -## Relacionado con -- ADR-YYY -- Feature F-XXX \ No newline at end of file diff --git a/spec/sdd/decisions/001-stack-tecnologico.md b/spec/sdd/decisions/001-stack-tecnologico.md deleted file mode 100644 index c30dcec..0000000 --- a/spec/sdd/decisions/001-stack-tecnologico.md +++ /dev/null @@ -1,63 +0,0 @@ -# ADR-001: Selección de Stack Técnico - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Necesitamos seleccionar el stack tecnológico inicial para el proyecto. El equipo tiene experiencia en Python y JavaScript/TypeScript, y requiere: -- Rápido bootstrap -- Testing BDD nativo -- Compatibilidad con el framework ARNES - -## Decisión -Usar **Python + Behave** para BDD y **FastAPI** para el backend. - -## Justificación -1. **Behave** tiene sintaxis Gherkin nativa y integración simple con Python -2. **FastAPI** ofrece validación automática con Pydantic y tests con pytest -3. Ambos tienen ecosistema maduro y documentación extensa -4. Comunidad activa y soporte a largo plazo - -## Consecuencias - -### ✅ Positivas -- Curva de aprendizaje baja (Python) -- BDD nativo con Behave (Gherkin) -- Type hints en todo el stack -- FastAPI: auto-generated docs (Swagger/ReDoc) -- Testing integrado con pytest - -### ❌ Negativas -- GIL限制了多线程性能 (puede mitigated with async) -- Menos opciones de hosting que Node.js - -### 🔄 Neutrales -- Requiere Python 3.10+ mínimo - -## Alternativas Consideradas - -### Opción A: Node.js + Cucumber -- **Pros**: Más opciones de hosting, JSON nativo, ecosistema npm enorme -- **Contras**: TypeScript requiere más setup, testing E2E más complejo -- **Razón de descarte**: Mayor complejidad inicial, menor familiaridad del equipo con TS - -### Opción B: Java + Cucumber-JVM -- **Pros**: Tipo estático, robusto, enterprise-grade -- **Contras**: Verbose, setup pesado, curva de aprendizaje alta -- **Razón de descarte**: Over-engineering para MVP - -### Opción C: Go + Godog -- **Pros**: Binarios estáticos, excelente performance -- **Contras**: BDD tooling inmaduro, less ecosystem para testing -- **Razón de descarte**: BDD ecosystem no maduro - -## Notas -- Re-evaluar si el proyecto escala a más de 50 servicios -- Considerar microservices framework si es necesario - -## Relacionado con -- Feature F-001 -- Stack: Python 3.11+, FastAPI, Behave, PostgreSQL \ No newline at end of file diff --git a/spec/sdd/decisions/002-almacenamiento-avatar.md b/spec/sdd/decisions/002-almacenamiento-avatar.md deleted file mode 100644 index 2c899ae..0000000 --- a/spec/sdd/decisions/002-almacenamiento-avatar.md +++ /dev/null @@ -1,69 +0,0 @@ -# ADR-002: Almacenamiento de Avatares - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Los usuarios pueden subir avatares personalizados. Necesitamos decidir dónde y cómo almacenar las imágenes de perfil para optimizar costo, rendimiento y mantenimiento. - -## Decisión -Usar **Storage Service externo (S3-compatible)** con URLs firmadas para avatares. - -## Justificación -1. **Simplicidad**: No requerimos procesar imágenes en nuestro servidor -2. **Costo**: S3-like storage es económico ($0.023/GB) -3. **CDN**: Los avatares se sirven desde CDN automáticamente -4. **Seguridad**: URLs firmadas con expiración evitan hotlinking -5. **Mantenimiento**: No requiere gestión de sistema de archivos - -## Consecuencias - -### ✅ Positivas -- No hay infraestructura de archivos que mantener -- Escalabilidad automática -- URLs firmadas = más seguridad -- Cache CDN = mejor performance - -### ❌ Negativas -- Dependencia de proveedor externo -- Costo de storage + egress -- Latencia extra por redirect a CDN - -### 🔄 Neutrales -- Requiere configuración de CORS - -## Alternativas Consideradas - -### Opción A: Almacenamiento local en servidor -- **Pros**: Sin dependencia externa, rápido para lecturas -- **Contras**: No escala horizontalmente, requiere backup, problemas de disco -- **Razón de descarte**: No escala bien con múltiples instancias - -### Opción B: Base de datos como BLOB -- **Pros**: Todo en un lugar, transacciones integradas -- **Contras**: PostgreSQL no optimizado para archivos grandes, backup lento -- **Razón de descarte**: degrada performance de DB, backups muy pesados - -### Opción C: Servicio dedicado de imágenes (Cloudinary/Imgix) -- **Pros**: Transformación de imágenes, CDN incluido, optimización automática -- **Contras**: Más costoso ($50+/mes), vendor lock-in -- **Razón de descarte**: Over-engineering para avatares simples - -## Implementación - -1. Cliente sube imagen a `/api/v1/profile/upload` (multipart) -2. Servicio valida tipo (jpg/png/webp) y tamaño (<5MB) -3. Servicio sube a S3 con nombre `avatars/{user_id}/{timestamp}.{ext}` -4. Servicio genera URL firmada (7 días validez) -5. URL se guarda en campo `avatar_url` del perfil - -## Notas -- Considerar WebP en el futuro para optimización -- Implementar cleanup de avatares huérfanos (job semanal) - -## Relacionado con -- Feature F-002 -- Componente: UserProfileService \ No newline at end of file diff --git a/spec/sdd/decisions/003-hashing-contrasena.md b/spec/sdd/decisions/003-hashing-contrasena.md deleted file mode 100644 index 83eb592..0000000 --- a/spec/sdd/decisions/003-hashing-contrasena.md +++ /dev/null @@ -1,83 +0,0 @@ -# ADR-003: Hashing de Contraseñas - -## Estado -Aceptado - -## Fecha -2026-05-06 - -## Contexto -Necesitamos guardar contraseñas de usuarios de forma segura. La decisión debe considerar: -- Resistencia a ataques de fuerza bruta y rainbow tables -- Performance (se ejecuta en cada login y cambio de password) -- Compatibilidad con estándares de la industria - -## Decisión -Usar **bcrypt** con cost factor 12 para hashing de contraseñas. - -## Justificación -1. **bcrypt** es diseñado específicamente para password hashing lento -2. **Cost factor configurable**: permite aumentar resistencia en el futuro -3. **Resistente a GPU/rainbow attacks**: diseñado para ser lento intencionalmente -4. **Incorpora salt**: cada password tiene salt único, evitando rainbow tables -5. **Estándar de industria**: ampliamente usado (Django, Rails, bcrypt) - -## Consecuencias - -### ✅ Positivas -- Resistente a ataques de fuerza bruta -- Salt automático evitar rainbow tables -- Configurable (cost factor) -- Librerías maduras en todos los lenguajes - -### ❌ Negativas -- Más lento que MD5/SHA (es el punto, pero afecta latency) -- Enorme payload si se guarda en cookies/token - -### 🔄 Neutrales -- Requiere Python 3.11+ para bcrypt moderno - -## Implementación - -```python -import bcrypt - -def hash_password(password: str) -> str: - """Hash password with bcrypt, cost 12.""" - return bcrypt.hashpw( - password.encode('utf-8'), - bcrypt.gensalt(rounds=12) - ).decode('utf-8') - -def verify_password(password: str, hashed: str) -> bool: - """Verify password using constant-time comparison.""" - return bcrypt.checkpw( - password.encode('utf-8'), - hashed.encode('utf-8') - ) -``` - -## Alternativas Consideradas - -### Opción A: SHA-256 (con salt) -- **Pros**: Rápido, simple -- **Contras**: No es lento, vulnerable a GPU attacks, diseñado para speed no security -- **Razón de descarte**: No es resistente a hardware moderno - -### Opción B: Argon2 -- **Pros**: Ganador PHC 2015, configurable memory/CPU -- **Contras**: Más complejo de implementar, menos soporte de librerías -- **Razón de descarte**: bcrypt es más simple y suficiente para nuestro caso de uso - -### Opción C: scrypt -- **Pros**: Diseñado para ser memory-hard -- **Contras**: Más lento de configurar, configuración compleja -- **Razón de descarte**: bcrypt es más simple y ampliamente soportado - -## Notas -- Si en el futuro,我们需要 mayor seguridad, migrar a Argon2 -- No guardar passwords en logs bajo ninguna circunstancia - -## Relacionado con -- Feature F-003 -- Componente: PasswordService \ No newline at end of file diff --git a/spec/sdd/decisions/004-jwt-auth.md b/spec/sdd/decisions/004-jwt-auth.md deleted file mode 100644 index f1ec33d..0000000 --- a/spec/sdd/decisions/004-jwt-auth.md +++ /dev/null @@ -1,68 +0,0 @@ -# ADR-004: JWT Authentication Strategy - -## Status -ACCEPTED - -## Context -We need a stateless authentication mechanism for the API that: -1. Allows users to login with email/password -2. Provides secure token-based sessions -3. Supports token revocation (logout) -4. Handles token refresh without re-login - -## Decision - -We will use **JWT (JSON Web Tokens)** with the following configuration: - -### Token Structure -- **Access Token**: 15 minute expiration, contains user identity -- **Refresh Token**: 7 day expiration, used to obtain new access tokens - -### Algorithm -- **HS256** for signing (symmetric, simpler setup) -- Secret key loaded from environment variable `JWT_SECRET` - -### Claims -```json -{ - "sub": "user_uuid", - "email": "user@example.com", - "role": "user", - "iat": 1715030400, - "exp": 1715031300, - "jti": "unique-token-id" -} -``` - -### Session Management -- Active sessions tracked in **Redis** (keyed by `jti`) -- Sessions invalidated on logout -- All user sessions invalidated on password change (from F-003) - -## Consequences - -### Positive -- Stateless = horizontal scaling friendly -- Short-lived access tokens limit damage if compromised -- Refresh tokens allow long sessions without storing passwords -- Redis-based session tracking enables instant revocation - -### Negative -- Cannot revoke individual refresh tokens (need blocklist) -- Token size larger than session IDs -- Clock sync required between services - -## Alternatives Considered - -| Alternative | Why Rejected | -|-------------|--------------| -| Session cookies | Not API-friendly, CSRF issues | -| OAuth2/OIDC | Overkill for simple auth | -| PASETO | Less battle-tested | -| opaque tokens | Requires DB lookup on every request | - -## Implementation Notes -- JWT library: PyJWT -- Redis client: aioredis for async -- Both tokens stored in HttpOnly cookies for browser clients -- Access token in Authorization header for API clients \ No newline at end of file diff --git a/spec/sdd/decisions/README.md b/spec/sdd/decisions/README.md new file mode 100644 index 0000000..30913fd --- /dev/null +++ b/spec/sdd/decisions/README.md @@ -0,0 +1,7 @@ +# SDD decisions + +Put ADRs (Architecture Decision Records) here. + +Example: +- `001-use-flask.md` +- `002-use-mariadb.md` diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 8ce43e9..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package init.""" \ No newline at end of file diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d8bbfec..0000000 Binary files a/src/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc deleted file mode 100644 index b6b6ed2..0000000 Binary files a/src/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/src/api/__init__.py b/src/api/__init__.py deleted file mode 100644 index 69f75d6..0000000 --- a/src/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API init.""" \ No newline at end of file diff --git a/src/api/__pycache__/__init__.cpython-313.pyc b/src/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3b4a2d6..0000000 Binary files a/src/api/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/api/__pycache__/auth.cpython-313.pyc b/src/api/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index f8437c1..0000000 Binary files a/src/api/__pycache__/auth.cpython-313.pyc and /dev/null differ diff --git a/src/api/__pycache__/password.cpython-313.pyc b/src/api/__pycache__/password.cpython-313.pyc deleted file mode 100644 index c0535bb..0000000 Binary files a/src/api/__pycache__/password.cpython-313.pyc and /dev/null differ diff --git a/src/api/auth.py b/src/api/auth.py deleted file mode 100644 index 0b2e103..0000000 --- a/src/api/auth.py +++ /dev/null @@ -1,220 +0,0 @@ -"""FastAPI endpoints for authentication.""" -from fastapi import APIRouter, Depends, HTTPException, status, Request, Header -from typing import Optional - -from src.models.auth import ( - LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, - AuthTokens, ErrorResponse, TokenValidationResult -) -from src.services.auth_service import ( - AuthService, auth_service, - InvalidCredentialsError, AccountLockedError, InvalidTokenError -) -from src.services.token_service import token_service -from src.services.session_store import session_store - -router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) - - -def get_client_ip(request: Request) -> str: - """Get client IP address from request.""" - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - -@router.post( - "/login", - response_model=LoginResponse, - responses={ - 401: {"model": ErrorResponse, "description": "Invalid credentials"}, - 429: {"model": ErrorResponse, "description": "Too many attempts"} - } -) -async def login( - request_body: LoginRequest, - request: Request, - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Authenticate user and return JWT tokens. - - Returns access token (15 min) and refresh token (7 days). - """ - try: - client_ip = get_client_ip(request) - result = auth_svc.login(request_body, client_ip) - - return LoginResponse( - success=True, - message="Login exitoso", - data=AuthTokens( - access_token=result.access_token, - refresh_token=result.refresh_token, - token_type="bearer", - expires_in=result.expires_in - ) - ) - - except InvalidCredentialsError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_credentials", - "message": "Credenciales inválidas" - } - ) - except AccountLockedError as e: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail={ - "error": "account_locked", - "message": str(e) - } - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "error": "internal_error", - "message": "Error interno del servidor" - } - ) - - -@router.post( - "/logout", - responses={ - 200: {"description": "Logout successful"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"} - } -) -async def logout( - request_body: LogoutRequest, - authorization: Optional[str] = Header(None), - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Invalidate current session (logout). - - Set revoke_all=true to invalidate all user sessions. - """ - # Extract token from Authorization header - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "not_authenticated", - "message": "Token requerido" - } - ) - - token = authorization.replace("Bearer ", "") - - try: - # Decode token to get token_id and user_id - payload = token_service.verify_token(token) - - if request_body.revoke_all: - count = auth_svc.logout_all(payload.sub) - return { - "success": True, - "message": f"Sesiones finalizadas: {count}" - } - else: - auth_svc.logout(payload.jti, payload.sub) - return { - "success": True, - "message": "Logout exitoso" - } - - except Exception as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_token", - "message": "Token inválido o expirado" - } - ) - - -@router.post( - "/refresh", - response_model=LoginResponse, - responses={ - 401: {"model": ErrorResponse, "description": "Invalid refresh token"} - } -) -async def refresh_token( - request_body: RefreshRequest, - auth_svc: AuthService = Depends(lambda: auth_service) -): - """ - Get new access token from refresh token. - - Use this endpoint when your access token has expired. - """ - try: - result = auth_svc.refresh(request_body.refresh_token) - - return LoginResponse( - success=True, - message="Token refrescado", - data=AuthTokens( - access_token=result.access_token, - refresh_token=result.refresh_token, - token_type="bearer", - expires_in=result.expires_in - ) - ) - - except InvalidTokenError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "invalid_token", - "message": "Refresh token inválido o expirado" - } - ) - - -@router.get( - "/validate", - response_model=TokenValidationResult -) -async def validate_token( - authorization: Optional[str] = Header(None) -): - """ - Validate an access token. - - Returns token payload if valid and session is active. - """ - if not authorization or not authorization.startswith("Bearer "): - return TokenValidationResult( - valid=False, - error="Token requerido" - ) - - token = authorization.replace("Bearer ", "") - - try: - payload = token_service.verify_token(token) - - # Also check if session is still valid - if not session_store.is_session_valid(payload.jti): - return TokenValidationResult( - valid=False, - error="Sesión revocada" - ) - - return TokenValidationResult( - valid=True, - payload=payload - ) - except Exception as e: - return TokenValidationResult( - valid=False, - error=str(e) - ) \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py deleted file mode 100644 index 3b5d872..0000000 --- a/src/api/main.py +++ /dev/null @@ -1,80 +0,0 @@ -"""FastAPI application for User Profile Service.""" -from fastapi import FastAPI, HTTPException, Header -from typing import Optional - -from src.models.profile import Profile, UpdateProfileRequest, ProfileResponse -from src.services.profile_service import profile_service - -app = FastAPI(title="User Profile Service", version="1.0.0") - - -def verify_owner(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@app.get("/api/v1/users/{user_id}/profile") -async def get_profile( - user_id: str, - authorization: Optional[str] = Header(None) -) -> ProfileResponse: - """Get user profile.""" - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - profile, status, error = profile_service.get_profile(user_id) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.put("/api/v1/users/{user_id}/profile") -async def update_profile( - user_id: str, - request: UpdateProfileRequest, - authorization: Optional[str] = Header(None) -) -> ProfileResponse: - """Update user profile.""" - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - # Verify ownership - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_owner(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para editar este perfil") - - profile, status, error = profile_service.update_profile(user_id, request) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.get("/health") -async def health(): - """Health check endpoint.""" - return {"status": "healthy", "service": "user-profile-service"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/api/password.py b/src/api/password.py deleted file mode 100644 index 1368e57..0000000 --- a/src/api/password.py +++ /dev/null @@ -1,89 +0,0 @@ -"""FastAPI endpoints for password management.""" -from fastapi import APIRouter, HTTPException, Header -from typing import Optional - -from src.models.password import ChangePasswordRequest, ChangePasswordResponse -from src.services.password_service import password_service - -router = APIRouter(prefix="/api/v1/users", tags=["password"]) - - -def verify_ownership(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@router.post("/{user_id}/change-password", response_model=ChangePasswordResponse) -async def change_password( - user_id: str, - request: ChangePasswordRequest, - authorization: Optional[str] = Header(None) -) -> ChangePasswordResponse: - """ - Change user's password. - - Requires authentication and ownership of the account. - """ - # Verify authentication - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - # Verify ownership - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_ownership(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para modificar esta cuenta") - - # Change password - success, status, error = password_service.change_password( - user_id, - request.current_password, - request.new_password, - request.confirm_password - ) - - if not success: - if status == 400: - raise HTTPException(status_code=400, detail=error) - elif status == 401: - raise HTTPException(status_code=401, detail=error) - elif status == 429: - raise HTTPException(status_code=429, detail=error) - elif status == 404: - raise HTTPException(status_code=404, detail=error) - else: - raise HTTPException(status_code=500, detail="Error interno") - - return ChangePasswordResponse( - success=True, - message="Contraseña actualizada exitosamente" - ) - - -@router.post("/{user_id}/validate-password") -async def validate_password( - user_id: str, - password: str, - authorization: Optional[str] = Header(None) -) -> dict: - """ - Validate password strength (for pre-check before form submission). - - This endpoint is useful for real-time validation in the UI. - """ - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - is_valid, error = password_service.validate_password_strength(password) - - return { - "valid": is_valid, - "error": error if not is_valid else None - } - - -# Register router in main app -def include_routes(app): - """Include password routes in the FastAPI app.""" - app.include_router(router) \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 74a89f4..0000000 --- a/src/main.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Main FastAPI application.""" -import os -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse - -from src.api.auth import router as auth_router -from src.api.password import router as password_router - -# Profile routes (from the original api/main.py) -from src.models.profile import Profile, UpdateProfileRequest, ProfileResponse -from src.services.profile_service import profile_service - -app = FastAPI( - title="ARNES API", - description="User management API with authentication", - version="1.0.0" -) - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers -app.include_router(auth_router) -app.include_router(password_router) - -# Serve UI static files -ui_path = os.path.join(os.path.dirname(__file__), "ui") -if os.path.exists(ui_path): - app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") - -def verify_owner(user_id: str, token: str | None) -> bool: - """Verify if the token belongs to the user (mock).""" - if token is None: - return False - return token == f"token_{user_id}" or token == "valid_token" - - -@app.get("/api/v1/users/{user_id}/profile") -async def get_profile( - user_id: str, - authorization=None -) -> ProfileResponse: - """Get user profile.""" - if not authorization: - from fastapi import HTTPException - raise HTTPException(status_code=401, detail="No autorizado") - - profile, status, error = profile_service.get_profile(user_id) - - if status == 404: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.put("/api/v1/users/{user_id}/profile") -async def update_profile( - user_id: str, - request: UpdateProfileRequest, - authorization=None -) -> ProfileResponse: - """Update user profile.""" - from fastapi import HTTPException - - if not authorization: - raise HTTPException(status_code=401, detail="No autorizado") - - token = authorization.replace("Bearer ", "") if authorization else None - if not verify_owner(user_id, token): - raise HTTPException(status_code=403, detail="No tienes permiso para editar este perfil") - - profile, status, error = profile_service.update_profile(user_id, request) - - if status == 404: - raise HTTPException(status_code=404, detail=error) - - return ProfileResponse( - id=profile.id, - name=profile.name, - avatar_url=profile.avatar_url, - language=profile.language, - created_at=profile.created_at.isoformat(), - updated_at=profile.updated_at.isoformat() - ) - - -@app.get("/") -async def root(): - """Redirect to UI login page.""" - return RedirectResponse(url="/ui/login.html") - - -@app.get("/health") -async def health(): - return {"status": "healthy"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index 418a2f8..0000000 --- a/src/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Models init.""" \ No newline at end of file diff --git a/src/models/__pycache__/__init__.cpython-313.pyc b/src/models/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9f9942d..0000000 Binary files a/src/models/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/models/__pycache__/auth.cpython-313.pyc b/src/models/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index 8aa6182..0000000 Binary files a/src/models/__pycache__/auth.cpython-313.pyc and /dev/null differ diff --git a/src/models/__pycache__/password.cpython-313.pyc b/src/models/__pycache__/password.cpython-313.pyc deleted file mode 100644 index af61288..0000000 Binary files a/src/models/__pycache__/password.cpython-313.pyc and /dev/null differ diff --git a/src/models/__pycache__/profile.cpython-313.pyc b/src/models/__pycache__/profile.cpython-313.pyc deleted file mode 100644 index be23d70..0000000 Binary files a/src/models/__pycache__/profile.cpython-313.pyc and /dev/null differ diff --git a/src/models/auth.py b/src/models/auth.py deleted file mode 100644 index 18dcb22..0000000 --- a/src/models/auth.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Request/Response models for authentication.""" -from pydantic import BaseModel, EmailStr, Field -from typing import Optional -from datetime import datetime - - -class LoginRequest(BaseModel): - """Login request body.""" - email: EmailStr = Field(..., max_length=255, description="User email") - password: str = Field(..., min_length=1, description="User password") - - -class TokenPayload(BaseModel): - """JWT token payload.""" - sub: str = Field(..., description="User ID") - email: str = Field(..., description="User email") - role: str = Field(default="user", description="User role") - iat: int = Field(..., description="Issued at timestamp") - exp: int = Field(..., description="Expiration timestamp") - jti: str = Field(..., description="JWT ID for revocation") - type: Optional[str] = Field(default=None, description="Token type (access/refresh)") - - class Config: - extra = "allow" # Allow extra fields like 'type' from JWT - - -class AuthTokens(BaseModel): - """Authentication tokens response.""" - access_token: str = Field(..., description="JWT access token") - refresh_token: str = Field(..., description="JWT refresh token") - token_type: str = Field(default="bearer", description="Token type") - expires_in: int = Field(..., description="Access token TTL in seconds") - - -class LoginResponse(BaseModel): - """Successful login response.""" - success: bool = Field(default=True) - message: str = Field(default="Login exitoso") - data: Optional[AuthTokens] = None - - -class RefreshRequest(BaseModel): - """Token refresh request.""" - refresh_token: str = Field(..., description="Valid refresh token") - - -class LogoutRequest(BaseModel): - """Logout request body.""" - revoke_all: bool = Field(default=False, description="Revoke all user sessions") - - -class ErrorResponse(BaseModel): - """Error response model.""" - error: str = Field(..., description="Error code") - message: str = Field(..., description="Human-readable message") - details: Optional[dict] = None - - -class TokenValidationResult(BaseModel): - """Token validation result.""" - valid: bool - payload: Optional[TokenPayload] = None - error: Optional[str] = None \ No newline at end of file diff --git a/src/models/password.py b/src/models/password.py deleted file mode 100644 index 608a6bb..0000000 --- a/src/models/password.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Password validation models.""" -from pydantic import BaseModel, Field, field_validator -import re - - -class ChangePasswordRequest(BaseModel): - """Request model for changing password.""" - - current_password: str = Field(..., min_length=1, description="Current password") - new_password: str = Field(..., min_length=8, max_length=128, description="New password") - confirm_password: str = Field(..., description="Confirm new password") - - @field_validator("new_password") - @classmethod - def validate_password_strength(cls, v: str) -> str: - """Validate password meets security requirements.""" - if len(v) < 8: - raise ValueError("La contraseña debe tener al menos 8 caracteres") - if len(v) > 128: - raise ValueError("La contraseña debe tener máximo 128 caracteres") - if not re.search(r'[A-Z]', v): - raise ValueError("La contraseña debe contener al menos una mayúscula") - if not re.search(r'[a-z]', v): - raise ValueError("La contraseña debe contener al menos una minúscula") - if not re.search(r'\d', v): - raise ValueError("La contraseña debe contener al menos un número") - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', v): - raise ValueError("La contraseña debe contener al menos un carácter especial (!@#$%^&*...)") - return v - - @field_validator("confirm_password") - @classmethod - def validate_match(cls, v: str, info) -> str: - """Validate passwords match.""" - return v - - -class ChangePasswordResponse(BaseModel): - """Response model for password change.""" - - success: bool - message: str - - -class PasswordValidationError(BaseModel): - """Error model for validation failures.""" - - field: str - message: str \ No newline at end of file diff --git a/src/models/profile.py b/src/models/profile.py deleted file mode 100644 index 15a59d9..0000000 --- a/src/models/profile.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Models for User Profile service.""" -from datetime import datetime -from typing import Literal -from pydantic import BaseModel, Field, field_validator - - -class Profile(BaseModel): - """User profile model.""" - - id: str - name: str = Field(..., min_length=2, max_length=50) - avatar_url: str = Field(default="", max_length=500) - language: Literal["en", "es", "fr", "de"] = "en" - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - """Validate name: only letters and spaces.""" - if not v.replace(" ", "").replace("á", "").replace("é", "").replace("í", "").replace("ó", "").replace("ú", "").replace("ñ", "").isalpha(): - raise ValueError("Nombre inválido: solo letras y espacios") - return v - - @field_validator("avatar_url") - @classmethod - def validate_avatar_url(cls, v: str) -> str: - """Validate avatar URL: must be http or https.""" - if v and not v.startswith(("http://", "https://")): - raise ValueError("Solo se permiten URLs http o https") - return v - - def to_dict(self) -> dict: - """Convert to dictionary.""" - return { - "id": self.id, - "name": self.name, - "avatar_url": self.avatar_url, - "language": self.language, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat() - } - - -class UpdateProfileRequest(BaseModel): - """Request model for updating profile.""" - - name: str | None = Field(None, min_length=2, max_length=50) - avatar_url: str | None = Field(None, max_length=500) - language: Literal["en", "es", "fr", "de"] | None = None - - @field_validator("name") - @classmethod - def validate_name(cls, v: str | None) -> str | None: - if v is not None and not v.replace(" ", "").isalpha(): - raise ValueError("Nombre inválido: solo letras y espacios") - return v - - @field_validator("avatar_url") - @classmethod - def validate_avatar_url(cls, v: str | None) -> str | None: - if v is not None and not v.startswith(("http://", "https://")): - raise ValueError("Solo se permiten URLs http o https") - return v - - -class ProfileResponse(BaseModel): - """Response model for profile operations.""" - - id: str - name: str - avatar_url: str - language: str - created_at: str - updated_at: str \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py deleted file mode 100644 index 79fd546..0000000 --- a/src/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Services init.""" \ No newline at end of file diff --git a/src/services/__pycache__/__init__.cpython-313.pyc b/src/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index cedef80..0000000 Binary files a/src/services/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/services/__pycache__/auth_service.cpython-313.pyc b/src/services/__pycache__/auth_service.cpython-313.pyc deleted file mode 100644 index 99d9987..0000000 Binary files a/src/services/__pycache__/auth_service.cpython-313.pyc and /dev/null differ diff --git a/src/services/__pycache__/password_service.cpython-313.pyc b/src/services/__pycache__/password_service.cpython-313.pyc deleted file mode 100644 index 00552c6..0000000 Binary files a/src/services/__pycache__/password_service.cpython-313.pyc and /dev/null differ diff --git a/src/services/__pycache__/profile_service.cpython-313.pyc b/src/services/__pycache__/profile_service.cpython-313.pyc deleted file mode 100644 index ac50492..0000000 Binary files a/src/services/__pycache__/profile_service.cpython-313.pyc and /dev/null differ diff --git a/src/services/__pycache__/session_store.cpython-313.pyc b/src/services/__pycache__/session_store.cpython-313.pyc deleted file mode 100644 index 37710fa..0000000 Binary files a/src/services/__pycache__/session_store.cpython-313.pyc and /dev/null differ diff --git a/src/services/__pycache__/token_service.cpython-313.pyc b/src/services/__pycache__/token_service.cpython-313.pyc deleted file mode 100644 index 221e5d0..0000000 Binary files a/src/services/__pycache__/token_service.cpython-313.pyc and /dev/null differ diff --git a/src/services/auth_service.py b/src/services/auth_service.py deleted file mode 100644 index 5482da6..0000000 --- a/src/services/auth_service.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Authentication service for login/logout operations.""" -from typing import Optional -from dataclasses import dataclass - -from src.models.auth import LoginRequest, AuthTokens, TokenPayload -from src.services.token_service import TokenService -from src.services.session_store import session_store as _session_store -from src.services.session_store import SessionStore as SessionStoreClass - - -class AuthError(Exception): - """Base authentication error.""" - pass - - -class InvalidCredentialsError(AuthError): - """Invalid email or password.""" - pass - - -class AccountLockedError(AuthError): - """Account temporarily locked due to rate limiting.""" - pass - - -class InvalidTokenError(AuthError): - """Token is invalid or expired.""" - pass - - -@dataclass -class AuthResult: - """Result of authentication operation.""" - success: bool - access_token: Optional[str] = None - refresh_token: Optional[str] = None - token_type: str = "bearer" - expires_in: int = 900 - error: Optional[str] = None - - -class AuthService: - """ - Service for user authentication. - - Handles login, logout, token refresh, and session management. - """ - - def __init__( - self, - token_svc: Optional[TokenService] = None, - session_store: Optional[SessionStoreClass] = None - ): - self._token_service = token_svc if token_svc else TokenService() - # Use the singleton session store to ensure consistency - self._session_store = session_store if session_store is not None else _session_store - self._rate_limit_store = {} # ip -> list of attempt timestamps - self._locked_accounts = {} # email -> lock_until timestamp - - def _check_rate_limit(self, ip_address: str) -> None: - """ - Check if IP is rate limited. - - Raises: - AccountLockedError: If rate limit exceeded - """ - import time - now = time.time() - window = 900 # 15 minutes - - if ip_address not in self._rate_limit_store: - self._rate_limit_store[ip_address] = [] - - # Clean old attempts - self._rate_limit_store[ip_address] = [ - ts for ts in self._rate_limit_store[ip_address] - if now - ts < window - ] - - max_attempts = 10 - if len(self._rate_limit_store[ip_address]) >= max_attempts: - raise AccountLockedError("Demasiados intentos. Intenta de nuevo en 15 minutos.") - - def _record_attempt(self, ip_address: str) -> None: - """Record a login attempt for rate limiting.""" - import time - now = time.time() - - if ip_address not in self._rate_limit_store: - self._rate_limit_store[ip_address] = [] - - self._rate_limit_store[ip_address].append(now) - - def _get_user_by_email(self, email: str) -> Optional[dict]: - """ - Get user from database by email. - - In production, this queries the users table. - For testing, returns mock users. - """ - # Mock user database - mock_users = { - "alice@example.com": { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "$2b$12$F6csT2WTMzNZgF1JevQN1uH.GcfuJId4J4e7CWTuUjeM4MQ6z2mUW", # SecurePass123! - "role": "user", - "active": True - }, - "bob@test.com": { - "id": "user-002", - "email": "bob@test.com", - "password_hash": "$2b$12$F6csT2WTMzNZgF1JevQN1uH.GcfuJId4J4e7CWTuUjeM4MQ6z2mUW", # Same password for testing - "role": "user", - "active": True - } - } - - return mock_users.get(email.lower()) - - def _verify_password(self, password: str, password_hash: str) -> bool: - """ - Verify password against hash. - Uses bcrypt for secure comparison. - """ - import bcrypt - try: - return bcrypt.checkpw( - password.encode('utf-8'), - password_hash.encode('utf-8') - ) - except Exception: - return False - - def login(self, request: LoginRequest, ip_address: Optional[str] = None) -> AuthResult: - """ - Authenticate user with email and password. - - Args: - request: LoginRequest with email and password - ip_address: Client IP for rate limiting - - Returns: - AuthResult with tokens if successful - - Raises: - InvalidCredentialsError: If credentials are wrong - AccountLockedError: If rate limit exceeded - """ - try: - # Check rate limit - self._check_rate_limit(ip_address or "unknown") - - # Get user - user = self._get_user_by_email(request.email) - - if not user: - self._record_attempt(ip_address or "unknown") - raise InvalidCredentialsError("Credenciales inválidas") - - # Check if account is active - if not user.get("active", True): - raise InvalidCredentialsError("Cuenta desactivada") - - # Verify password - if not self._verify_password(request.password, user["password_hash"]): - self._record_attempt(ip_address or "unknown") - raise InvalidCredentialsError("Credenciales inválidas") - - # Generate tokens - access_token, token_id = self._token_service.create_access_token( - user["id"], - user["email"], - user["role"] - ) - refresh_token = self._token_service.create_refresh_token( - user["id"], - user["email"], - user["role"] - ) - - # Store session - self._session_store.create_session( - user["id"], - token_id, - ip_address - ) - - return AuthResult( - success=True, - access_token=access_token, - refresh_token=refresh_token, - expires_in=900 - ) - - except (InvalidCredentialsError, AccountLockedError): - raise - except Exception as e: - raise AuthError(f"Login failed: {str(e)}") - - def logout(self, token_id: str, user_id: Optional[str] = None) -> bool: - """ - Invalidate a specific session/token. - - Args: - token_id: JWT jti (token identifier) - user_id: User ID (optional, for validation) - - Returns: - True if successful - """ - return self._session_store.revoke_session(token_id) - - def logout_all(self, user_id: str) -> int: - """ - Invalidate all sessions for a user. - - Args: - user_id: User ID - - Returns: - Number of sessions invalidated - """ - return self._session_store.revoke_all_user_sessions(user_id) - - def refresh(self, refresh_token: str) -> AuthResult: - """ - Get new access token from refresh token. - - Args: - refresh_token: Valid refresh token - - Returns: - AuthResult with new access token - - Raises: - InvalidTokenError: If refresh token is invalid - """ - try: - # Verify refresh token - payload = self._token_service.verify_token(refresh_token) - - # Check token type - if getattr(payload, 'type', 'access') != 'refresh': - raise InvalidTokenError("Invalid token type") - - # Check if session is still valid - if not self._session_store.is_session_valid(payload.jti): - raise InvalidTokenError("Session revoked") - - # Generate new access token - access_token, token_id = self._token_service.create_access_token( - payload.sub, - payload.email, - payload.role - ) - - # Create new session for the new token - self._session_store.create_session(payload.sub, token_id) - - return AuthResult( - success=True, - access_token=access_token, - refresh_token=refresh_token, # Return same refresh token - expires_in=900 - ) - - except Exception as e: - if isinstance(e, (InvalidTokenError, Exception)): - raise InvalidTokenError(f"Refresh failed: {str(e)}") - raise - - def validate_token(self, token: str) -> tuple[bool, Optional[TokenPayload], Optional[str]]: - """ - Validate an access token. - - Returns: - Tuple of (is_valid, payload, error_message) - """ - try: - payload = self._token_service.verify_token(token) - - # Check if session is still valid - if not self._session_store.is_session_valid(payload.jti): - return False, None, "Session revoked" - - return True, payload, None - - except Exception as e: - error_msg = str(e) - if "expired" in error_msg.lower(): - return False, None, "Token expired" - return False, None, "Invalid token" - - -# Singleton instance - use the shared session_store singleton -auth_service = AuthService() -auth_service._session_store = _session_store \ No newline at end of file diff --git a/src/services/password_service.py b/src/services/password_service.py deleted file mode 100644 index b34ff8c..0000000 --- a/src/services/password_service.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Password Service - business logic for password management.""" -import re -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Literal - - -@dataclass -class PasswordHistory: - """Tracks password history to prevent reuse.""" - user_id: str - hashed_passwords: list[str] = field(default_factory=list) - max_history: int = 3 - - def add(self, hashed_password: str): - """Add password to history, maintaining max size.""" - self.hashed_passwords.append(hashed_password) - if len(self.hashed_passwords) > self.max_history: - self.hashed_passwords.pop(0) - - def is_reused(self, password: str) -> bool: - """Check if password was used recently.""" - return password in self.hashed_passwords - - -class PasswordService: - """Service for password management operations.""" - - MAX_HISTORY = 3 - RATE_LIMIT_ATTEMPTS = 5 - RATE_LIMIT_WINDOW_HOURS = 1 - - def __init__(self): - self._users: dict[str, dict] = {} # Mock user storage - self._password_history: dict[str, PasswordHistory] = {} - self._rate_limits: dict[str, list[datetime]] = {} - self._sessions: dict[str, list[str]] = {} # user_id -> list of session tokens - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock user data.""" - self._users = { - "user-123": { - "id": "user-123", - "email": "user@example.com", - "password_hash": "OldPass123!" - }, - "user-456": { - "id": "user-456", - "email": "other@example.com", - "password_hash": "OtherPass456!" - } - } - - def _hash_password(self, password: str) -> str: - """Hash password (mock implementation).""" - # In production: bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) - return password - - def _verify_password(self, password: str, hashed: str) -> bool: - """Verify password against hash (mock implementation).""" - # In production: bcrypt.checkpw(password.encode(), hashed.encode()) - return password == hashed - - def _is_rate_limited(self, user_id: str) -> bool: - """Check if user is rate limited.""" - if user_id not in self._rate_limits: - return False - - # Clean old attempts - window = timedelta(hours=self.RATE_LIMIT_WINDOW_HOURS) - cutoff = datetime.now() - window - self._rate_limits[user_id] = [ - t for t in self._rate_limits[user_id] if t > cutoff - ] - - return len(self._rate_limits[user_id]) >= self.MAX_HISTORY - - def _record_attempt(self, user_id: str): - """Record password change attempt.""" - if user_id not in self._rate_limits: - self._rate_limits[user_id] = [] - self._rate_limits[user_id].append(datetime.now()) - - def _get_history(self, user_id: str) -> PasswordHistory: - """Get or create password history for user.""" - if user_id not in self._password_history: - self._password_history[user_id] = PasswordHistory(user_id) - return self._password_history[user_id] - - def change_password( - self, - user_id: str, - current_password: str, - new_password: str, - confirm_password: str - ) -> tuple[bool, int, str | None]: - """ - Change user's password. - - Returns: - (success, status_code, error_message) - """ - # Check user exists - if user_id not in self._users: - return False, 404, "Usuario no encontrado" - - # Check rate limit - if self._is_rate_limited(user_id): - return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora" - - # Record attempt - self._record_attempt(user_id) - - user = self._users[user_id] - - # Verify current password - if not self._verify_password(current_password, user["password_hash"]): - return False, 401, "La contraseña actual es incorrecta" - - # Verify passwords match - if new_password != confirm_password: - return False, 400, "Las contraseñas no coinciden" - - # Check password history - history = self._get_history(user_id) - if history.is_reused(new_password): - return False, 400, "La nueva contraseña no puede ser igual a la anterior" - - # Validate password strength - is_valid, strength_error = self.validate_password_strength(new_password) - if not is_valid: - return False, 400, strength_error - - # Change password - hashed_new = self._hash_password(new_password) - history.add(user["password_hash"]) # Save old hash before changing - user["password_hash"] = hashed_new - - # Invalidate all sessions - self._sessions[user_id] = [] - - return True, 200, None - - def validate_password_strength(self, password: str) -> tuple[bool, str]: - """ - Validate password meets security requirements. - - Returns: - (is_valid, error_message) - """ - if len(password) < 8: - return False, "La contraseña debe tener al menos 8 caracteres" - if len(password) > 128: - return False, "La contraseña debe tener máximo 128 caracteres" - if not re.search(r'[A-Z]', password): - return False, "La contraseña debe contener al menos una mayúscula" - if not re.search(r'[a-z]', password): - return False, "La contraseña debe contener al menos una minúscula" - if not re.search(r'\d', password): - return False, "La contraseña debe contener al menos un número" - if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password): - return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" - return True, "" - - -# Singleton instance -password_service = PasswordService() \ No newline at end of file diff --git a/src/services/profile_service.py b/src/services/profile_service.py deleted file mode 100644 index 59e324b..0000000 --- a/src/services/profile_service.py +++ /dev/null @@ -1,86 +0,0 @@ -"""User Profile Service - main business logic.""" -from datetime import datetime -from typing import Literal - -from src.models.profile import Profile, UpdateProfileRequest - - -class ProfileService: - """Service for managing user profiles.""" - - def __init__(self): - self._profiles: dict[str, Profile] = {} - self._init_mock_data() - - def _init_mock_data(self): - """Initialize mock data for testing.""" - self._profiles = { - "user-123": Profile( - id="user-123", - name="Juan Pérez", - avatar_url="https://cdn.example.com/avatar-123.jpg", - language="es" - ), - "user-456": Profile( - id="user-456", - name="María García", - avatar_url="https://cdn.example.com/avatar-456.jpg", - language="en" - ) - } - - def get_profile(self, user_id: str) -> tuple[Profile | None, int, str | None]: - """ - Get user profile by ID. - - Returns: - tuple: (profile, status_code, error_message) - """ - if user_id not in self._profiles: - return None, 404, "Usuario no encontrado" - return self._profiles[user_id], 200, None - - def update_profile( - self, - user_id: str, - request: UpdateProfileRequest - ) -> tuple[Profile | None, int, str | None]: - """ - Update user profile. - - Returns: - tuple: (profile, status_code, error_message) - """ - if user_id not in self._profiles: - return None, 404, "Usuario no encontrado" - - profile = self._profiles[user_id] - - # Update fields - if request.name is not None: - profile.name = request.name - - if request.avatar_url is not None: - profile.avatar_url = request.avatar_url - - if request.language is not None: - profile.language = request.language - - profile.updated_at = datetime.now() - - return profile, 200, None - - def create_profile(self, user_id: str, name: str, avatar_url: str = "", language: str = "en") -> Profile: - """Create a new profile.""" - profile = Profile( - id=user_id, - name=name, - avatar_url=avatar_url, - language=language - ) - self._profiles[user_id] = profile - return profile - - -# Singleton instance -profile_service = ProfileService() \ No newline at end of file diff --git a/src/services/session_store.py b/src/services/session_store.py deleted file mode 100644 index cb76414..0000000 --- a/src/services/session_store.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Session store for managing active sessions in Redis.""" -import json -from typing import Optional -from dataclasses import dataclass, asdict -from datetime import datetime -import hashlib - - -@dataclass -class Session: - """Session data structure.""" - user_id: str - token_id: str - created_at: str - ip_address: Optional[str] = None - user_agent: Optional[str] = None - last_activity: Optional[str] = None - - -class SessionStore: - """ - In-memory session store (simulating Redis for testing). - - In production, replace with Redis: - - session:{user_id}:{token_id} -> JSON session metadata - - user_sessions:{user_id} -> SET of active token_ids - """ - - def __init__(self): - self._sessions = {} # token_id -> Session - self._user_sessions = {} # user_id -> set of token_ids - self._revoked_tokens = set() # token_ids that are revoked - - def create_session( - self, - user_id: str, - token_id: str, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None - ) -> bool: - """ - Store a new active session. - """ - session = Session( - user_id=user_id, - token_id=token_id, - created_at=datetime.utcnow().isoformat(), - ip_address=ip_address, - user_agent=user_agent, - last_activity=datetime.utcnow().isoformat() - ) - - self._sessions[token_id] = session - - if user_id not in self._user_sessions: - self._user_sessions[user_id] = set() - self._user_sessions[user_id].add(token_id) - - # Limit sessions per user - max_sessions = 10 - if len(self._user_sessions[user_id]) > max_sessions: - oldest = min(self._user_sessions[user_id]) - self.revoke_session(oldest) - - return True - - def get_session(self, token_id: str) -> Optional[Session]: - """ - Get session by token ID. - Returns None if not found or revoked. - """ - if token_id in self._revoked_tokens: - return None - return self._sessions.get(token_id) - - def revoke_session(self, token_id: str) -> bool: - """ - Revoke a specific session. - """ - session = self._sessions.get(token_id) - if session: - self._revoked_tokens.add(token_id) - del self._sessions[token_id] - - if session.user_id in self._user_sessions: - self._user_sessions[session.user_id].discard(token_id) - - return True - return False - - def revoke_all_user_sessions(self, user_id: str) -> int: - """ - Revoke all sessions for a user. - Returns count of revoked sessions. - """ - if user_id not in self._user_sessions: - return 0 - - count = 0 - for token_id in list(self._user_sessions[user_id]): - self.revoke_session(token_id) - count += 1 - - return count - - def get_user_session_count(self, user_id: str) -> int: - """Count active sessions for a user.""" - return len(self._user_sessions.get(user_id, set())) - - def is_session_valid(self, token_id: str) -> bool: - """Check if session is valid (exists and not revoked).""" - return token_id in self._sessions and token_id not in self._revoked_tokens - - def cleanup_expired(self) -> int: - """ - Remove expired sessions. - Returns count of cleaned sessions. - """ - # In real Redis, use TTL and SCAN for this - return 0 - - def clear_all(self) -> None: - """Clear all sessions (for testing).""" - self._sessions.clear() - self._user_sessions.clear() - self._revoked_tokens.clear() - - -# Singleton instance -session_store = SessionStore() \ No newline at end of file diff --git a/src/services/token_service.py b/src/services/token_service.py deleted file mode 100644 index c1a5a30..0000000 --- a/src/services/token_service.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Token service for JWT generation and validation.""" -import os -import uuid -from datetime import datetime, timezone, timedelta -from typing import Optional - -import jwt -from jwt.exceptions import InvalidTokenError, ExpiredSignatureError - -from src.models.auth import TokenPayload - - -class TokenService: - """Service for JWT token operations.""" - - def __init__(self): - self.secret_key = os.getenv("JWT_SECRET", "dev-secret-key-change-in-prod") - self.algorithm = "HS256" - self.access_token_expire = 900 # 15 minutes - self.refresh_token_expire = 604800 # 7 days - - def _now_timestamp(self) -> int: - """Get current UTC timestamp (uses time.time() for accuracy).""" - import time - return int(time.time()) - - def create_access_token(self, user_id: str, email: str, role: str = "user") -> tuple[str, str]: - """ - Generate a new JWT access token. - - Returns: - Tuple of (token_string, token_id/jti) - """ - token_id = str(uuid.uuid4()) - now = self._now_timestamp() - exp = now + self.access_token_expire - - payload = { - "sub": user_id, - "email": email, - "role": role, - "iat": now, - "exp": exp, - "jti": token_id - } - - token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) - return token, token_id - - def create_refresh_token(self, user_id: str, email: str, role: str = "user") -> str: - """ - Generate a new refresh token. - """ - now = self._now_timestamp() - exp = now + self.refresh_token_expire - - payload = { - "sub": user_id, - "email": email, - "role": role, - "iat": now, - "exp": exp, - "jti": str(uuid.uuid4()), - "type": "refresh" - } - - token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) - return token - - def verify_token(self, token: str) -> TokenPayload: - """ - Validate and decode a JWT token. - - Returns: - TokenPayload if valid - - Raises: - InvalidTokenError: If token is invalid - ExpiredSignatureError: If token has expired - """ - try: - payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) - return TokenPayload(**payload) - except jwt.DecodeError as e: - raise InvalidTokenError(f"Invalid token: {str(e)}") - - def decode_token_unsafe(self, token: str) -> Optional[dict]: - """ - Decode token without validation (for debugging). - """ - try: - return jwt.decode(token, options={"verify_signature": False}) - except Exception: - return None - - def is_token_expired(self, token: str) -> bool: - """Check if token is expired without raising exception.""" - try: - self.verify_token(token) - return False - except ExpiredSignatureError: - return True - except InvalidTokenError: - return True - - def get_token_claims(self, token: str) -> Optional[dict]: - """Get token claims without full validation.""" - try: - payload = jwt.decode( - token, - self.secret_key, - algorithms=[self.algorithm], - options={"verify_exp": False} - ) - return payload - except Exception: - return None - - -# Singleton instance -token_service = TokenService() \ No newline at end of file diff --git a/src/ui/change-password.html b/src/ui/change-password.html deleted file mode 100644 index d728a9b..0000000 --- a/src/ui/change-password.html +++ /dev/null @@ -1,359 +0,0 @@ - - - - - - Cambiar Contraseña - ARNES - - - -
-

🔑 Cambiar Contraseña

-

Actualiza tu contraseña de seguridad

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

📊 ARNES Dashboard

-
- - -
-
- -
-
-
-

🔐 Estado de Autenticación

-
-

Token activo: Verificando...

-
- -
-
- -
-

🔑 Operaciones

-
-

Acciones disponibles para el usuario autenticado:

- -
-
- -
-

🧪 Debug Info

-
-

API Base:

-

Token expiry:

-
- Access Token:
- - -
-
-
-
- -
-
- - - - \ No newline at end of file diff --git a/src/ui/login.html b/src/ui/login.html deleted file mode 100644 index 07000a6..0000000 --- a/src/ui/login.html +++ /dev/null @@ -1,325 +0,0 @@ - - - - - - Iniciar Sesión - ARNES - - - - - -
✅ ¡Bienvenido!
- - - - \ No newline at end of file diff --git a/starter-pack/README.md b/starter-pack/README.md index b5776bc..ae0c8f2 100644 --- a/starter-pack/README.md +++ b/starter-pack/README.md @@ -4,15 +4,15 @@ Este pack sirve para arrancar ARNES en 2 escenarios: ## A) Proyecto nuevo (greenfield) 1. Crea repo vacío. -2. Copia el template ARNES. +2. Instala el core ARNES desde otro repo fuente (`scripts/install_into_repo.sh`). 3. Ajusta `backlog/features.json` (`project`, `description`). -4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature. +4. Copia `starter-pack/backlog.features.bootstrap.json` como primera feature (`type=chore`). 5. Ejecuta: - `./scripts/verify.sh` - `python3 scripts/agent_status.py show` ## B) Proyecto ya empezado (brownfield) -1. Copia **solo** carpetas core ARNES: `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`. +1. Instala **solo** el core ARNES dentro del repo existente. 2. Mantén tu código actual intacto. 3. Añade checks del dominio en `scripts/verify.local.sh`. 4. Define features reales del proyecto en `backlog/features.json`. @@ -22,5 +22,7 @@ Este pack sirve para arrancar ARNES en 2 escenarios: ## Reglas mínimas - 1 sola feature en `in_progress`. +- Tipos válidos: `feature`, `fix`, `bug`, `chore`. - `done` requiere gates: `review/security/qa`. +- `done` requiere commit+push final del ticket. - Evidencia en `work/artifacts//`. diff --git a/starter-pack/backlog.features.bootstrap.json b/starter-pack/backlog.features.bootstrap.json index 712c52c..2cddcf6 100644 --- a/starter-pack/backlog.features.bootstrap.json +++ b/starter-pack/backlog.features.bootstrap.json @@ -1,11 +1,25 @@ { "id": "F-001", - "title": "Bootstrap de proyecto con ARNES", - "description": "Configurar pipeline SDD en este repositorio y validar primer ciclo completo.", + "type": "chore", + "title": "Bootstrap ARNES on project", + "problem": "Need base workflow and control", + "goal": "Make ARNES ready on this repo", + "scope_in": [ + "Harness setup", + "Runtime status", + "First verify cycle" + ], + "scope_out": [ + "Business feature work", + "Product redesign" + ], + "priority": "med", + "risk": "low", + "description": "Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.", "acceptance": [ - "verify.sh en verde", - "runtime-status operativo", - "primera feature cerrada con gates" + "verify.sh is green", + "runtime status works", + "first feature closes with gates" ], "status": "pending", "created_at": "YYYY-MM-DD", diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index a013d54..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests init.""" \ No newline at end of file diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 77249a1..0000000 Binary files a/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index a013d54..0000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests init.""" \ No newline at end of file diff --git a/tests/unit/__pycache__/__init__.cpython-313.pyc b/tests/unit/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 1375c6d..0000000 Binary files a/tests/unit/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/tests/unit/__pycache__/test_auth.cpython-313.pyc b/tests/unit/__pycache__/test_auth.cpython-313.pyc deleted file mode 100644 index a6a5c3b..0000000 Binary files a/tests/unit/__pycache__/test_auth.cpython-313.pyc and /dev/null differ diff --git a/tests/unit/__pycache__/test_password.cpython-313.pyc b/tests/unit/__pycache__/test_password.cpython-313.pyc deleted file mode 100644 index de104e7..0000000 Binary files a/tests/unit/__pycache__/test_password.cpython-313.pyc and /dev/null differ diff --git a/tests/unit/__pycache__/test_profile.cpython-313.pyc b/tests/unit/__pycache__/test_profile.cpython-313.pyc deleted file mode 100644 index 0fe74ef..0000000 Binary files a/tests/unit/__pycache__/test_profile.cpython-313.pyc and /dev/null differ diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py deleted file mode 100644 index d1d02cf..0000000 --- a/tests/unit/test_auth.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Unit tests for authentication service.""" -import unittest -from unittest.mock import MagicMock, patch - -from src.models.auth import LoginRequest, TokenPayload -from src.services.auth_service import ( - AuthService, auth_service, - InvalidCredentialsError, AccountLockedError, InvalidTokenError -) -from src.services.token_service import TokenService -from src.services.session_store import SessionStore - - -class TestAuthService(unittest.TestCase): - """Test cases for AuthService.""" - - def setUp(self): - """Set up test fixtures.""" - self.mock_token_service = MagicMock(spec=TokenService) - self.mock_session_store = MagicMock(spec=SessionStore) - - self.auth_service = AuthService( - token_svc=self.mock_token_service, - session_store=self.mock_session_store - ) - - # Default mock returns - self.mock_token_service.create_access_token.return_value = ("access_token_123", "token_id_123") - self.mock_token_service.create_refresh_token.return_value = "refresh_token_456" - - def test_login_success(self): - """Test successful login.""" - request = LoginRequest(email="alice@example.com", password="SecurePass123!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": True - } - - with patch.object(self.auth_service, '_verify_password', return_value=True): - result = self.auth_service.login(request, "127.0.0.1") - - self.assertTrue(result.success) - self.assertEqual(result.access_token, "access_token_123") - self.assertEqual(result.refresh_token, "refresh_token_456") - self.mock_session_store.create_session.assert_called_once() - - def test_login_invalid_credentials(self): - """Test login with invalid credentials.""" - request = LoginRequest(email="alice@example.com", password="WrongPassword!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": True - } - - with patch.object(self.auth_service, '_verify_password', return_value=False): - with self.assertRaises(InvalidCredentialsError): - self.auth_service.login(request, "127.0.0.1") - - def test_login_nonexistent_user(self): - """Test login with nonexistent user.""" - request = LoginRequest(email="nonexistent@test.com", password="AnyPassword123!") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - with self.assertRaises(InvalidCredentialsError): - self.auth_service.login(request, "127.0.0.1") - - def test_login_inactive_account(self): - """Test login with inactive account.""" - request = LoginRequest(email="alice@example.com", password="SecurePass123!") - - with patch.object(self.auth_service, '_get_user_by_email') as mock_get_user: - mock_get_user.return_value = { - "id": "user-001", - "email": "alice@example.com", - "password_hash": "hashed_password", - "role": "user", - "active": False - } - - with self.assertRaises(InvalidCredentialsError) as ctx: - self.auth_service.login(request, "127.0.0.1") - - self.assertIn("desactivada", str(ctx.exception)) - - def test_logout_success(self): - """Test successful logout.""" - result = self.auth_service.logout("token_id_123", "user-001") - - self.assertTrue(result) - self.mock_session_store.revoke_session.assert_called_once_with("token_id_123") - - def test_logout_all_sessions(self): - """Test logout all sessions.""" - self.mock_session_store.revoke_all_user_sessions.return_value = 3 - - result = self.auth_service.logout_all("user-001") - - self.assertEqual(result, 3) - self.mock_session_store.revoke_all_user_sessions.assert_called_once_with("user-001") - - def test_refresh_success(self): - """Test successful token refresh.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123", - type="refresh" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = True - self.mock_token_service.create_access_token.return_value = ("new_access_token", "new_token_id") - - result = self.auth_service.refresh("valid_refresh_token") - - self.assertTrue(result.success) - self.assertEqual(result.access_token, "new_access_token") - - def test_refresh_invalid_token(self): - """Test refresh with invalid token.""" - self.mock_token_service.verify_token.side_effect = InvalidTokenError("Invalid token") - - with self.assertRaises(InvalidTokenError): - self.auth_service.refresh("invalid_token") - - def test_refresh_revoked_session(self): - """Test refresh with revoked session.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123", - type="refresh" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = False - - with self.assertRaises(InvalidTokenError): - self.auth_service.refresh("valid_refresh_token") - - def test_validate_token_valid(self): - """Test token validation with valid token.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = True - - is_valid, payload, error = self.auth_service.validate_token("valid_token") - - self.assertTrue(is_valid) - self.assertEqual(payload.sub, "user-001") - self.assertIsNone(error) - - def test_validate_token_revoked(self): - """Test token validation with revoked session.""" - mock_payload = TokenPayload( - sub="user-001", - email="alice@example.com", - role="user", - iat=1234567890, - exp=1234567890 + 900, - jti="token_id_123" - ) - - self.mock_token_service.verify_token.return_value = mock_payload - self.mock_session_store.is_session_valid.return_value = False - - is_valid, payload, error = self.auth_service.validate_token("valid_token") - - self.assertFalse(is_valid) - self.assertEqual(error, "Session revoked") - - def test_validate_token_expired(self): - """Test token validation with expired token.""" - from jwt.exceptions import ExpiredSignatureError - - self.mock_token_service.verify_token.side_effect = ExpiredSignatureError("Token expired") - - is_valid, payload, error = self.auth_service.validate_token("expired_token") - - self.assertFalse(is_valid) - self.assertIn("expired", error.lower()) - - -class TestRateLimiting(unittest.TestCase): - """Test rate limiting functionality.""" - - def setUp(self): - self.auth_service = AuthService() - - def test_rate_limit_allows_first_attempts(self): - """Test rate limit allows initial attempts.""" - import time - ip = "10.0.0.1" - - # Clear any existing rate limit data - self.auth_service._rate_limit_store = {ip: []} - - request = LoginRequest(email="test@test.com", password="wrong") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - # First 9 attempts should raise InvalidCredentialsError (not locked) - for i in range(9): - try: - self.auth_service.login(request, ip) - except InvalidCredentialsError: - pass # Expected - user doesn't exist - except AccountLockedError: - self.fail("Should not be locked after 9 attempts") - - # 10th attempt should still raise InvalidCredentialsError (not locked yet) - try: - self.auth_service.login(request, ip) - except InvalidCredentialsError: - pass # Still not locked - except AccountLockedError: - self.fail("Should not be locked after 10 attempts") - - # Verify rate limit counter is at 10 - self.assertEqual(len(self.auth_service._rate_limit_store[ip]), 10) - - def test_rate_limit_blocks_after_threshold(self): - """Test rate limit blocks after threshold.""" - import time - ip = "10.0.0.2" - - # Pre-fill rate limit - now = time.time() - self.auth_service._rate_limit_store[ip] = [now - 50] * 10 - - request = LoginRequest(email="test@test.com", password="wrong") - - with patch.object(self.auth_service, '_get_user_by_email', return_value=None): - with self.assertRaises(AccountLockedError): - self.auth_service.login(request, ip) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/unit/test_password.py b/tests/unit/test_password.py deleted file mode 100644 index 9f4c2e8..0000000 --- a/tests/unit/test_password.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Unit tests for password service.""" -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -import unittest -from src.services.password_service import PasswordService - - -class TestPasswordValidator(unittest.TestCase): - """Tests for password validation.""" - - def setUp(self): - self.service = PasswordService() - - def test_valid_password_all_requirements(self): - """Test password with all requirements met.""" - is_valid, error = self.service.validate_password_strength("Password123!") - self.assertTrue(is_valid) - self.assertEqual(error, "") - - def test_password_too_short(self): - """Test password shorter than 8 characters.""" - is_valid, error = self.service.validate_password_strength("Pass1!") - self.assertFalse(is_valid) - self.assertIn("al menos 8 caracteres", error) - - def test_password_too_long(self): - """Test password longer than 128 characters.""" - long_pass = "A" * 129 + "a1!" - is_valid, error = self.service.validate_password_strength(long_pass) - self.assertFalse(is_valid) - self.assertIn("máximo 128 caracteres", error) - - def test_password_no_uppercase(self): - """Test password without uppercase letter.""" - is_valid, error = self.service.validate_password_strength("password123!") - self.assertFalse(is_valid) - self.assertIn("al menos una mayúscula", error) - - def test_password_no_lowercase(self): - """Test password without lowercase letter.""" - is_valid, error = self.service.validate_password_strength("PASSWORD123!") - self.assertFalse(is_valid) - self.assertIn("al menos una minúscula", error) - - def test_password_no_number(self): - """Test password without number.""" - is_valid, error = self.service.validate_password_strength("PasswordABC!") - self.assertFalse(is_valid) - self.assertIn("al menos un número", error) - - def test_password_no_special_char(self): - """Test password without special character.""" - is_valid, error = self.service.validate_password_strength("Password123") - self.assertFalse(is_valid) - self.assertIn("carácter especial", error) - - -class TestPasswordService(unittest.TestCase): - """Tests for password service operations.""" - - def setUp(self): - self.service = PasswordService() - - def test_change_password_success(self): - """Test successful password change.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertTrue(success) - self.assertEqual(status, 200) - self.assertIsNone(error) - - def test_change_password_wrong_current(self): - """Test password change with wrong current password.""" - success, status, error = self.service.change_password( - "user-123", - "WrongPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 401) - self.assertEqual(error, "La contraseña actual es incorrecta") - - def test_change_password_mismatch(self): - """Test password change with mismatching passwords.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "DifferentPass789!" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - self.assertEqual(error, "Las contraseñas no coinciden") - - def test_change_password_weak(self): - """Test password change with weak password.""" - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "weak", - "weak" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - - def test_change_password_nonexistent_user(self): - """Test password change for nonexistent user.""" - success, status, error = self.service.change_password( - "nonexistent", - "AnyPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 404) - self.assertEqual(error, "Usuario no encontrado") - - def test_change_password_reuse_history(self): - """Test password change with password from history.""" - # First change - self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - # Try to reuse current (which is now in history) - success, status, error = self.service.change_password( - "user-123", - "NewPass456@", - "OldPass123!", - "OldPass123!" - ) - self.assertFalse(success) - self.assertEqual(status, 400) - self.assertIn("no puede ser igual a la anterior", error) - - def test_rate_limit_after_5_attempts(self): - """Test rate limiting after 5 failed attempts.""" - # Make 5 failed attempts - for _ in range(5): - self.service.change_password( - "user-123", - "WrongPass123!", - "NewPass456@", - "NewPass456@" - ) - - # 6th attempt should be rate limited - success, status, error = self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - self.assertFalse(success) - self.assertEqual(status, 429) - self.assertIn("Demasiados intentos", error) - - def test_sessions_invalidated_after_change(self): - """Test that sessions are invalidated after password change.""" - # Add some sessions - self.service._sessions["user-123"] = ["token1", "token2", "token3"] - - # Change password - self.service.change_password( - "user-123", - "OldPass123!", - "NewPass456@", - "NewPass456@" - ) - - # Sessions should be cleared - self.assertEqual(len(self.service._sessions.get("user-123", [])), 0) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py deleted file mode 100644 index 0c94c4b..0000000 --- a/tests/unit/test_profile.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Unit tests for profile service using unittest.""" -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -import unittest -from src.models.profile import Profile, UpdateProfileRequest -from src.services.profile_service import ProfileService - - -class TestProfileModel(unittest.TestCase): - """Tests for Profile model.""" - - def test_profile_creation(self): - """Test creating a profile.""" - profile = Profile(id="user-1", name="Juan Pérez", language="es") - self.assertEqual(profile.id, "user-1") - self.assertEqual(profile.name, "Juan Pérez") - self.assertEqual(profile.language, "es") - - def test_profile_name_validation_letters(self): - """Test valid name with letters only.""" - profile = Profile(id="user-1", name="José García") - self.assertEqual(profile.name, "José García") - - def test_profile_name_validation_invalid_chars(self): - """Test invalid name with special characters.""" - with self.assertRaises(ValueError) as ctx: - Profile(id="user-1", name="Juan@123!") - self.assertIn("Nombre inválido", str(ctx.exception)) - - def test_profile_avatar_url_valid(self): - """Test valid avatar URL.""" - profile = Profile(id="user-1", name="Test", avatar_url="https://cdn.example.com/img.jpg") - self.assertEqual(profile.avatar_url, "https://cdn.example.com/img.jpg") - - def test_profile_avatar_url_invalid_protocol(self): - """Test invalid avatar URL protocol.""" - with self.assertRaises(ValueError) as ctx: - Profile(id="user-1", name="Test", avatar_url="ftp://malicious.com/file.jpg") - self.assertIn("Solo se permiten", str(ctx.exception)) - - def test_profile_language_values(self): - """Test valid language values.""" - for lang in ["en", "es", "fr", "de"]: - profile = Profile(id="user-1", name="Test", language=lang) - self.assertEqual(profile.language, lang) - - -class TestUpdateProfileRequest(unittest.TestCase): - """Tests for UpdateProfileRequest model.""" - - def test_partial_update_name_only(self): - """Test updating only name.""" - request = UpdateProfileRequest(name="Pedro") - self.assertEqual(request.name, "Pedro") - self.assertIsNone(request.avatar_url) - self.assertIsNone(request.language) - - def test_partial_update_all_fields(self): - """Test updating all fields.""" - request = UpdateProfileRequest( - name="Pedro", - avatar_url="https://cdn.example.com/new.jpg", - language="en" - ) - self.assertEqual(request.name, "Pedro") - self.assertEqual(request.avatar_url, "https://cdn.example.com/new.jpg") - self.assertEqual(request.language, "en") - - -class TestProfileService(unittest.TestCase): - """Tests for ProfileService.""" - - def setUp(self): - """Create a fresh service instance.""" - self.service = ProfileService() - - def test_get_existing_profile(self): - """Test getting an existing profile.""" - profile, status, error = self.service.get_profile("user-123") - self.assertEqual(status, 200) - self.assertIsNotNone(profile) - self.assertEqual(profile.id, "user-123") - self.assertIsNone(error) - - def test_get_nonexistent_profile(self): - """Test getting a nonexistent profile.""" - profile, status, error = self.service.get_profile("nonexistent") - self.assertEqual(status, 404) - self.assertIsNone(profile) - self.assertEqual(error, "Usuario no encontrado") - - def test_update_profile_name(self): - """Test updating profile name.""" - request = UpdateProfileRequest(name="Pedro") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.name, "Pedro") - - def test_update_profile_avatar(self): - """Test updating profile avatar.""" - request = UpdateProfileRequest(avatar_url="https://cdn.example.com/new.jpg") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.avatar_url, "https://cdn.example.com/new.jpg") - - def test_update_profile_language(self): - """Test updating profile language.""" - request = UpdateProfileRequest(language="fr") - profile, status, error = self.service.update_profile("user-123", request) - self.assertEqual(status, 200) - self.assertEqual(profile.language, "fr") - - def test_update_nonexistent_profile(self): - """Test updating a nonexistent profile.""" - request = UpdateProfileRequest(name="Test") - profile, status, error = self.service.update_profile("nonexistent", request) - self.assertEqual(status, 404) - self.assertEqual(error, "Usuario no encontrado") - - def test_create_profile(self): - """Test creating a new profile.""" - profile = self.service.create_profile("user-new", "New User", "https://cdn.com/img.jpg", "es") - self.assertEqual(profile.id, "user-new") - self.assertEqual(profile.name, "New User") - self.assertEqual(profile.language, "es") - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file