commit 3897113313f36602d0542236870bd48050477768 Author: Josep Piguillem Date: Wed Aug 19 17:55:27 2026 +0200 Initial commit: Pireon Admin — headless admin panel and public API Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8c6221 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.github +node_modules +vendor +storage/app/public/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +storage/logs/* + +# The package manifest is built on whatever machine ran composer, and on a +# development machine that includes dev-only service providers. Copying it into +# a --no-dev image makes Laravel try to boot a class that is not there. +bootstrap/cache/*.php +database/*.sqlite +database/*.sqlite-journal +.env +.env.* +!.env.example +.phpunit.result.cache +docs/*.pdf +public/storage diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e084d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,188 @@ +APP_NAME="Pireon Admin" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +APP_LOCALE=es +APP_FALLBACK_LOCALE=es +APP_FAKER_LOCALE=es_ES + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +############################################################################### +# Pireon OAuth2 identity server +# +# Register this app as an OAuth client at https://oauth.pireon.dev and paste the +# credentials here. The redirect URI must match the one registered there exactly. +############################################################################### +PIREON_SERVER=https://oauth.pireon.dev +PIREON_CLIENT_ID= +PIREON_CLIENT_SECRET= +PIREON_REDIRECT_URI="${APP_URL}/auth/callback" +PIREON_SCOPES=account:read + +# Grants. The panel's sign-in form uses the password grant, so turning this off +# locks everyone out of the panel as well as out of POST /api/v1/auth/token. +PIREON_PASSWORD_GRANT=true + +# How long an API bearer token stays valid here after being revoked upstream. +PIREON_INTROSPECTION_TTL=60 +PIREON_HTTP_TIMEOUT=10 + +# Role granted on a user's first sign-in. +PIREON_DEFAULT_ROLE=viewer + +# Refuse sessions for accounts the identity server reports as inactive or +# still awaiting administrator approval. +PIREON_REQUIRE_ACTIVE=true +PIREON_REQUIRE_APPROVED=true + +############################################################################### +# Public site (separate, server-rendered application) +############################################################################### +SITE_URL=http://localhost:4321 + +# Frontend i18n routers serve the default locale without a prefix. Turn this +# on only if yours prefixes every locale. +SITE_PREFIX_DEFAULT_LOCALE=false + +# Shared secret letting the frontend render unpublished content. +# Leave empty to disable previews entirely. Generate one with: +# php -r "echo bin2hex(random_bytes(24)), PHP_EOL;" +SITE_PREVIEW_TOKEN= + +# Seconds a shared cache may serve a public response without revalidating. +SITE_CACHE_SECONDS=60 + +# Requests per minute on the public API. With SSR every visitor's page view +# arrives from the frontend's IP, so this is a budget for the WHOLE site at +# once. Add that IP to SITE_TRUSTED_IPS to bypass the limit entirely. +SITE_RATE_LIMIT=600 +SITE_TRUSTED_IPS= + +# Shared secret the frontend presents when reporting addresses it could not +# resolve (X-Ingest-Token). Those reports are what turns Redirects from +# something only this side can write into a list of links actually broken out +# there. Leave empty to disable the endpoint — empty never means "anyone may +# report". Generate one with: +# php -r "echo bin2hex(random_bytes(24)), PHP_EOL;" +SITE_INGEST_TOKEN= + +# Reports per minute, and paths per request. +SITE_TELEMETRY_RATE_LIMIT=60 +SITE_TELEMETRY_MAX_BATCH=50 + +# Submissions — the only public write endpoints. Set to false to close the +# forms without deploying; the frontend reads the flag from /site and hides +# them rather than showing a form that would be refused. +SITE_ACCEPT_SUBMISSIONS=true + +# Field name no human fills in. Anything arriving with it set is filed as +# spam — filed, not dropped, so a browser autofill cannot lose a real message. +SITE_HONEYPOT_FIELD=website + +# Per e-mail address, per minute; and across the whole site, per hour. There is +# deliberately no per-IP budget: with SSR every submission arrives from the +# frontend's address. +SITE_SUBMISSION_RATE_LIMIT=3 +SITE_SUBMISSION_HOURLY_LIMIT=60 +SITE_SUBMISSION_DEDUPE_MINUTES=1440 + +# Also e-mail the panel users who can read the inbox (needs a working mailer). +SITE_SUBMISSION_MAIL=false + +############################################################################### +# Images +############################################################################### +# Disk uploads live on. It has to be one with public URLs: the frontend is a +# separate application on another host. `public` needs `php artisan storage:link` +# once. If you have uploads from before this setting existed they are on the +# private `local` disk — either move them to storage/app/public or set this to +# `local`, which is served through the application's own /storage route. +MEDIA_DISK=public + +# Images above this many pixels are recorded with their dimensions but not +# resampled: resizing one costs more memory than it is worth, and it is nearly +# always a mistake rather than an intention. +MEDIA_MAX_PIXELS=50000000 + +############################################################################### +# Git repositories — self-hosted Gitea +############################################################################### +GIT_PROVIDER=gitea + +# Instance root, WITHOUT /api/v1 — the driver appends it. +GITEA_URL=https://git.example.org + +# Only needed for private repositories, or if the instance requires sign-in to +# read. Gitea → Settings → Applications. Narrowest useful scopes: +# read:repository, read:issue. +GITEA_TOKEN= + +# Organisation used when a repository is entered as a bare name. +GITEA_ORG= + +# Optional: only for mirrors or the odd repository hosted on GitHub. +GITHUB_TOKEN= +GITHUB_ORG= + +# hourly | daily | or a raw cron expression +GIT_SYNC_CRON=hourly +GIT_SYNC_TTL=55 +GIT_MAX_ISSUES=25 +GIT_HTTP_TIMEOUT=15 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..9f85cb9 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,118 @@ +############################################################################### +# Production environment for the Docker stack. +# +# cp .env.production.example .env.production +# docker compose run --rm --no-deps app php artisan key:generate --show +# +# Everything the containers need is here; docker-compose.yml sets the database, +# cache, session and queue drivers itself so they cannot drift. +############################################################################### + +APP_NAME="Pireon Admin" +APP_ENV=production +APP_DEBUG=false + +# Required. Without it sessions cannot be decrypted and the signed preview +# links cannot be verified — the entrypoint refuses to start rather than +# failing later in a way nobody connects to this. +APP_KEY= + +# The public address of the panel itself, https included. It is what the API +# builds absolute image URLs from, so getting it wrong means every image on the +# public site points at the wrong host. +APP_URL=https://admin.example.org + +APP_LOCALE=es +APP_FALLBACK_LOCALE=es + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_LEVEL=warning + +# Port the stack listens on. Put a reverse proxy in front of it and terminate +# TLS there. +APP_PORT=8000 + +############################################################################### +# Database +############################################################################### +DB_DATABASE=pireon +DB_USERNAME=pireon + +# No default on purpose: compose refuses to start without it. +DB_PASSWORD= + +############################################################################### +# Pireon OAuth2 identity server +# +# The panel's sign-in form uses the password grant, so the registered client +# must allow it. +############################################################################### +PIREON_SERVER=https://oauth.pireon.dev +PIREON_CLIENT_ID= +PIREON_CLIENT_SECRET= +PIREON_REDIRECT_URI="${APP_URL}/auth/callback" +PIREON_SCOPES=account:read +PIREON_PASSWORD_GRANT=true +PIREON_INTROSPECTION_TTL=60 +PIREON_DEFAULT_ROLE=viewer +PIREON_REQUIRE_ACTIVE=true +PIREON_REQUIRE_APPROVED=true + +############################################################################### +# Public site (separate, server-rendered application) +############################################################################### +SITE_URL=https://example.org +SITE_PREFIX_DEFAULT_LOCALE=false +SITE_CACHE_SECONDS=60 + +# Generate with: php -r "echo bin2hex(random_bytes(24)), PHP_EOL;" +SITE_PREVIEW_TOKEN= + +# With SSR every visitor's page view arrives from the frontend's address, so +# this budget is for the whole site at once. Add that address here and it +# bypasses the limit entirely. +SITE_RATE_LIMIT=600 +SITE_TRUSTED_IPS= + +# Shared secret for the 404 reports the frontend sends back. Empty disables +# the endpoint. Generate with: php -r "echo bin2hex(random_bytes(24)), PHP_EOL;" +SITE_INGEST_TOKEN= +SITE_TELEMETRY_RATE_LIMIT=60 +SITE_TELEMETRY_MAX_BATCH=50 + +SITE_ACCEPT_SUBMISSIONS=true +SITE_HONEYPOT_FIELD=website +SITE_SUBMISSION_RATE_LIMIT=3 +SITE_SUBMISSION_HOURLY_LIMIT=60 +SITE_SUBMISSION_DEDUPE_MINUTES=1440 +SITE_SUBMISSION_MAIL=false + +############################################################################### +# Images +# +# `public` is right for this stack: the storage volume is mounted in the web +# container and Caddy serves /storage/* straight off disk without touching PHP. +############################################################################### +MEDIA_DISK=public +MEDIA_MAX_PIXELS=50000000 + +############################################################################### +# Git repositories +############################################################################### +GIT_PROVIDER=gitea +GITEA_URL= +GITEA_TOKEN= +GITEA_ORG= +GIT_SYNC_CRON=hourly + +############################################################################### +# Mail — only needed if SITE_SUBMISSION_MAIL is true +############################################################################### +MAIL_MAILER=log +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS="admin@example.org" +MAIL_FROM_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d5dba --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.log +.DS_Store +.env +.env.backup +.env.backup-* +.env.production +.phpactor.json +.phpunit.result.cache +/.claude/settings.local.json +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d5affa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,122 @@ +# syntax=docker/dockerfile:1 + +############################################################################### +# Base +# +# Both stages share it, so dependencies are resolved against exactly the PHP +# that will run them — Filament requires ext-intl, and a composer image that +# lacks it would either refuse to install or, worse, install something the +# runtime cannot use. +############################################################################### +# PHP 8.4, not the 8.3 that composer.json's `^8.3` suggests: the lock file +# resolved Symfony 8, which requires >=8.4.1. Building on 8.3 fails at +# `composer install`, which is a much better place to find out than a deploy. +FROM dunglas/frankenphp:1-php8.4 AS base + +WORKDIR /app + +# gd with WebP is not optional: it is what App\Services\Media\ImageProcessor +# derives every responsive version with. exif is what stops a photo taken +# sideways staying sideways. pcntl lets the queue worker shut down cleanly +# instead of being killed mid-job. +RUN install-php-extensions \ + pdo_pgsql \ + gd \ + exif \ + intl \ + zip \ + pcntl \ + opcache \ + && apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# In base rather than only in runtime, so the test stage runs under the same +# PHP settings production does — a memory limit that is only set in production +# is a memory limit nothing has tried. +COPY docker/php.ini /usr/local/etc/php/conf.d/app.ini + +############################################################################### +# Dependencies +############################################################################### +FROM base AS vendor + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY composer.json composer.lock ./ + +# `--no-scripts` because the post-install hooks boot the application, and the +# application cannot boot before its code is here. They are run explicitly in +# the runtime stage instead, where everything exists. +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + +COPY . . + +RUN composer dump-autoload --no-dev --optimize --classmap-authoritative + +############################################################################### +# Test +# +# The production image is built with --no-dev, which leaves out the test runner +# itself. Verifying this application against Postgres therefore needs its own +# stage — see docker-compose.test.yml, and the JSON-column queries it exists to +# exercise. +############################################################################### +FROM base AS test + +# Only the dev dependencies need this one, so production stays without it. +RUN install-php-extensions bcmath + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY composer.json composer.lock ./ + +RUN composer install --no-scripts --no-autoloader --prefer-dist --no-interaction + +COPY . . + +# An empty .env: the file is excluded from the build context on purpose, and +# without it every single test emits a "failed to open stream" warning that +# buries the real output. +RUN touch .env \ + && composer dump-autoload --optimize \ + && php artisan package:discover --ansi + +############################################################################### +# Runtime +############################################################################### +FROM base AS runtime + +LABEL org.opencontainers.image.title="Pireon Admin" \ + org.opencontainers.image.description="Admin panel and REST API for the collective" + +COPY docker/Caddyfile /etc/caddy/Caddyfile + +COPY --from=vendor /app/vendor ./vendor +COPY . . + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint +RUN chmod +x /usr/local/bin/entrypoint + +# Filament serves its own CSS and JS from public/; publishing them at build +# time means the running container never writes to public/. +# +# There is deliberately no Node stage: the only `@vite` directive left is in +# Laravel's default welcome page, which this application redirects away from. +RUN php artisan package:discover --ansi \ + && php artisan filament:assets + +RUN chown -R www-data:www-data storage bootstrap/cache + +ENV SERVER_NAME=:80 + +EXPOSE 80 + +ENTRYPOINT ["entrypoint"] + +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9d930d --- /dev/null +++ b/README.md @@ -0,0 +1,1572 @@ +# Pireon Admin + +Panel de administración y API REST en Laravel 13 + Filament 5 para un colectivo +de programadores sin ánimo de lucro. Autenticación delegada en el servidor de +identidad **Pireon** (`https://oauth.pireon.dev`). + +**Este repositorio es solo admin + API.** La web pública es una aplicación +aparte, con **renderizado en servidor (SSR)**, que consume `/api/public/v1`. Con +qué framework esté hecha —Astro, Qwik, Angular— es indiferente para este lado, y +por eso no se nombra ninguno en el código: lo único que esta API da por supuesto +es el SSR. Aquí no hay maquetación: el renderizado, las rutas, el sitemap y las +etiquetas `hreflang` son cosa del frontend. Lo que sí vive aquí es el dato que +necesita para hacerlo. + +## Las dos ideas que lo estructuran todo + +**1. El servidor OAuth dice quién eres; esta aplicación decide qué puedes hacer.** + +| | Lo gestiona | Dónde se cambia | +|---|---|---| +| Nombre, email, cuenta activa/aprobada | Servidor de identidad | `oauth.pireon.dev` | +| Roles y permisos | Esta aplicación | Panel → Administración → Roles | +| Contenido, idiomas, repositorios | Esta aplicación | Panel | + +Los `app_roles` que devuelve el servidor OAuth se guardan en `users.remote_roles` +como información de apoyo, pero **no conceden nada**. + +**2. Un miembro del colectivo no es un usuario del panel.** + +La mayoría de la gente que aparece en la web nunca necesita entrar al admin. +`Member` tiene una relación *opcional* con `User`, así que quitarle a alguien el +acceso de administración no lo borra del sitio. + +## Manual de uso + +Para quien **usa** el panel (no para quien lo despliega) hay un manual aparte: +[`docs/manual-de-uso.pdf`](docs/manual-de-uso.pdf) — 26 páginas cubriendo +idiomas y traducciones que se quedan atrás, proyectos, publicación programada, la vitrina, +convocatorias, el buzón, imágenes, texto alternativo y galerías, miembros, radar, +repositorios de Gitea, pedir revisión, redirecciones y URLs que nadie encuentra, +salud del sitio y visitas, papelera, historial, mantenimiento y la tabla de qué +puede hacer cada rol. + +Se genera desde [`docs/manual-de-uso.html`](docs/manual-de-uso.html): + +```bash +node docs/build-pdf.mjs +``` + +## Puesta en marcha + +```bash +composer install +cp .env.example .env # si aún no existe +php artisan key:generate +php artisan migrate --seed # crea roles, permisos y contenido de ejemplo +``` + +### 1. Registrar la aplicación en el servidor de identidad + +En `https://oauth.pireon.dev` crea un cliente OAuth con: + +- **Grant types**: `password` — es el que usa el formulario de acceso del panel +- **Redirect URI**: `https://tu-dominio/auth/callback` (en local, + `http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de + redirección, que sigue disponible aunque el panel ya no lo use + +Copia las credenciales al `.env`: + +```dotenv +PIREON_SERVER=https://oauth.pireon.dev +PIREON_CLIENT_ID=... +PIREON_CLIENT_SECRET=... +PIREON_REDIRECT_URI="${APP_URL}/auth/callback" +``` + +Mientras `PIREON_CLIENT_ID` esté vacío, la pantalla de login lo indica en vez +de fallar contra el servidor. + +### 2. Crear el primer administrador + +No hay usuarios locales hasta que alguien entra por OAuth. El arranque es: + +```bash +php artisan serve +# entra en http://localhost:8000/admin y haz login con tu cuenta de Pireon +php artisan user:role tu@email.com super_admin +``` + +Tu primer login te deja con el rol `viewer` (configurable en +`PIREON_DEFAULT_ROLE`); el comando te promociona a super admin. A partir de ahí +todo se gestiona desde el panel. + +## Autenticación + +### Panel — email y contraseña + +`/admin/login` es un formulario normal. Quien entra no ve nada de OAuth: el +servidor de identidad es un detalle de implementación. + +``` +POST /admin/login → grant "password" contra oauth.pireon.dev + → GET /api/user para saber quién es + → crea o actualiza el usuario local, inicia sesión +``` + +**No hay contraseñas locales.** La columna `password` no se usa nunca: quien +decide si las credenciales son correctas sigue siendo el servidor de identidad. +Desactivar a alguien allí, o aquí (`is_active = false`), le corta el acceso +**a la próxima petición** — la sesión ya abierta seguía existiendo hasta ahora, +sin que nadie pudiera verla ni cerrarla desde aquí. + +En **Administración → Usuarios → [nombre] → Sesiones** se ve desde dónde +tiene cada cuenta la sesión abierta ahora mismo — IP, navegador, última +actividad — leído directamente de la tabla `sessions` que el driver +`database` de Laravel ya mantiene; no se escribe nada nuevo, solo se hace +visible. Con botón de **Cerrar sesión** por cada una, salvo la que la propia +persona que mira la pantalla está usando en ese momento: ofrecer cerrarla +dejaría un clic capaz de desconectarte a ti mismo a mitad de gestionar a otro. + +El precio de esta comodidad, dicho claro: **esta aplicación ve la contraseña**. +No la guarda, no la registra y no sale de la petición en la que llega —va +directa al servidor de identidad—, pero un compromiso de este panel expondría +credenciales del servidor de identidad, cosa que el flujo de redirección hacía +imposible. Es una decisión deliberada, no un descuido. + +Lo que sí se mantiene: + +- **Cinco intentos** por IP y a esperar (el limitador de Filament), para no + gastar el del servidor de identidad. +- **Un solo mensaje de error** para credenciales incorrectas y para direcciones + que no existen: el formulario no sirve para averiguar quién tiene cuenta. +- **Pero un servidor de identidad caído no se disfraza de contraseña + incorrecta.** Si responde 5xx o no responde, el mensaje lo dice: no ha + juzgado nada sobre esas credenciales, y hacer creer lo contrario manda a + alguien a revisar una contraseña que nunca fue el problema. Lo mismo en + `POST /api/v1/auth/token`, que devuelve 503 en vez de 401. +- **Rotación del id de sesión** al entrar, contra fijación de sesión. +- Cuenta desactivada, pendiente de aprobación o sin rol en este panel: mensajes + distintos, pero solo **después** de acertar la contraseña. + +Las rutas de **Authorization Code + PKCE** siguen ahí +([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php), +`/auth/redirect` y `/auth/callback`) y funcionando, simplemente ya no se +anuncian. Volver a ese flujo es cambiar la página de login del panel. + +Requiere `PIREON_PASSWORD_GRANT=true` y que el cliente OAuth registrado +permita el grant `password`. Si está desactivado, el formulario lo dice en vez +de fingir que las credenciales son incorrectas. + +Los tokens viven en la sesión ([`TokenStore`](app/Services/Pireon/TokenStore.php)), +se refrescan solos al caducar y se borran al cerrar sesión. + +### API — Bearer token + +El servidor de identidad no publica JWKS ni endpoint de introspección, así que +[`PireonTokenGuard`](app/Auth/PireonTokenGuard.php) valida cada token +gastándolo contra `GET /api/user`. El resultado se cachea unos segundos +(`PIREON_INTROSPECTION_TTL`), indexado por un hash SHA-256 del token — el token +en sí nunca llega al almacén de caché. + +Ese TTL es la ventana durante la cual un token revocado arriba sigue +funcionando aquí. Mantenlo bajo. + +Desactivar un usuario en este panel (`is_active = false`) le corta el acceso al +instante, sin tocar el servidor OAuth. + +### Password grant + +Para clientes que no pueden abrir un navegador (tests, apps móviles): + +```bash +curl -X POST http://localhost:8000/api/v1/auth/token \ + -H "Content-Type: application/json" \ + -d '{"grant_type":"password","username":"tu@email.com","password":"..."}' +``` + +Es el mismo grant que usa el formulario del panel, así que +`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez. + +## Permisos + +El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php). + +```php +public const RESOURCES = [ + 'pages' => [...self::CRUD_ABILITIES, 'publish'], + 'categories' => self::CRUD_ABILITIES, + 'projects' => [...self::CRUD_ABILITIES, 'publish'], + 'members' => self::CRUD_ABILITIES, + 'redirects' => ['view_any', 'view', 'create', 'update', 'delete'], + 'services' => [...self::CRUD_ABILITIES, 'publish'], + 'clients' => self::CRUD_ABILITIES, + 'testimonials' => self::CRUD_ABILITIES, + 'talks' => ['view_any', 'view', 'create', 'update', 'delete'], + 'technologies' => ['view_any', 'view', 'create', 'update', 'delete'], + 'calls' => ['view_any', 'view', 'create', 'update', 'delete'], + 'applications' => ['view_any', 'view', 'update', 'delete', 'restore', 'force_delete'], + 'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'], + 'users' => ['view_any', 'view', 'create', 'update', 'delete'], + 'roles' => ['view_any', 'view', 'create', 'update', 'delete'], + 'settings' => ['view', 'update', 'maintenance'], +]; +``` + +Añade un permiso ahí y ejecuta `php artisan permissions:sync`: aparece +automáticamente en el editor de roles, en las policies y en la API. El sync es +idempotente y borra los permisos que quites del catálogo. + +Roles que se siembran: + +- **super_admin** — sin permisos explícitos; lo puede todo por un + `Gate::before`, así que nunca se queda desactualizado +- **admin** — todo el catálogo +- **editor** — todo el contenido (páginas, proyectos, miembros, tecnologías, + convocatorias) y el buzón, porque quien lanza una convocatoria es quien tiene + que contestar a quien responde; repositorios, usuarios y ajustes en solo + lectura +- **writer** — escribe páginas, proyectos y servicios y **no puede + publicarlos**: es el rol para el que existían los permisos `publish` sueltos, + y el que hace que el paso de [revisión](#revisión) tenga sentido. Sin buzón + (mismo motivo que el viewer) y sin borrado definitivo +- **viewer** — lectura de contenido, **sin** acceso al buzón + +Las [policies](app/Policies/) las comparten el panel y la API, que es lo que +impide que las dos superficies se desincronicen. Incluyen las salvaguardas +habituales: nadie se borra a sí mismo, no se elimina el último super admin, +solo un super admin concede `super_admin`, y una categoría con páginas no se +puede borrar. + +Estos permisos están separados a propósito: + +- `pages.publish` / `projects.publish` / `services.publish` — se puede escribir + sin poder poner nada en la web pública. Es lo que hace posible el paso de + [revisión](#revisión). +- `repositories.sync` — lanzar una sincronización gasta el rate limit del + proveedor de git, que es un recurso compartido por toda la aplicación. +- `settings.maintenance` — tumbar la web pública es un acto deliberado y muy + visible, así que no va con el resto de ajustes. +- `applications.*` — el buzón guarda nombres, emails y mensajes de gente ajena + al colectivo. No hay `applications.create`: las solicitudes llegan de la web, + nadie las escribe a mano. + +## Multilanguage + +Tres idiomas — **es** (por defecto), **en**, **ca** — configurados en +[`config/locales.php`](config/locales.php). + +**Interfaz**: `laravel-lang` cubre validación y mensajes de framework; las +cadenas propias están en `lang/{es,en,ca}/{panel,pireon,rules,enums}.php`. + +**Contenido**: `Page`, `Category`, `Project`, `Member`, `Technology` y `Call` +usan `spatie/laravel-translatable`. Los +campos traducibles se guardan como JSON `{"es": "...", "en": "...", "ca": "..."}`. +En el panel se editan con una pestaña por idioma +([`TranslatableTabs`](app/Filament/Support/TranslatableTabs.php)), y una columna +de estado muestra de un vistazo qué falta por traducir. + +Orden de resolución del idioma (de más a menos específico): + +1. `?lang=ca` +2. sesión (lo que eligió en el selector) +3. `users.locale` +4. cabecera `Accept-Language` +5. `locales.default` + +En la API los campos traducibles se devuelven resueltos a un idioma. Con +`?with=translations` llegan todos: + +```bash +curl '.../api/v1/pages?lang=ca' # title: "Inici" +curl '.../api/v1/pages?with=translations' # translations.title.{es,en,ca} +``` + +Al escribir, un objeto parcial **fusiona**: enviar `{"title":{"ca":"..."}}` no +borra el castellano. + +Los slugs son únicos **por idioma** ([`UniqueTranslatedSlug`](app/Rules/UniqueTranslatedSlug.php)); +el mismo slug en dos idiomas distintos es válido. Al vivir dentro de JSON no +puede ser una restricción de base de datos, así que se valida en la aplicación. + +Esa regla es dura y solo mira la dirección. Lo que no ve es un **título** casi +idéntico con un slug distinto — el caso real: alguien no recuerda que el +proyecto ya existe y empieza uno nuevo. Al escribir el título en páginas, +proyectos y servicios, si algo existente se le parece mucho ([`DuplicateTitles`](app/Support/DuplicateTitles.php), +comparación por semejanza de caracteres, sin operador de "parecido" común entre +SQLite y Postgres), sale un aviso con la ficha existente enlazada. **No bloquea +el guardado** — a veces el segundo es de verdad otra cosa que se llama casi +igual —, solo evita que nadie se entere tarde. + +## El modelo de contenido + +Cuatro modelos, pensados para lo que un colectivo así necesita de verdad: + +**Proyectos** — además de lo obvio, dos campos que son la razón de ser del +modelo: `decisions` (por qué se eligió lo que se eligió) y `postmortem` (qué +salió mal). Es lo que una consultora comercial no puede publicar y vosotros sí, +y es lo que otros desarrolladores leen. Y `client`: para qué ONG o asociación se +hizo, ahora como registro propio y no como texto suelto. + +**Servicios** — qué ofrece el colectivo, enlazado a los proyectos que lo +demuestran. **Clientes** — para quién se hizo, con logo. **Testimonios** — qué +dijeron, colgado de lo que avalan. **Charlas** — dónde se ha contado. + +**Miembros** — perfil público con bio traducible, stack y enlaces. Es el +escaparate personal de cada uno, que es la moneda con la que se paga el trabajo +voluntario. Quien se va conserva su historial (`left_at`) en vez de desaparecer. + +**Tecnologías** — vocabulario compartido entre proyectos, miembros y +convocatorias, ahora con **logo propio** además del color —`icon` guardaba el +nombre de un icono de un set que el frontend puede no traer, y la mayoría de +tecnologías no están en ninguno—, y fuente del **tech radar** (`adopt` / `trial` / `assess` / +`hold`, con la nota que explica el porqué). El `name` no se traduce: «Rust» es +«Rust» en los tres idiomas; solo la prosa se traduce. + +**Convocatorias** — el punto de conversión. La clave son `hours_per_week` y +`duration_weeks`: en un colectivo por hobby lo escaso es el tiempo, y «~4h/semana +durante 8 semanas» permite decidir en diez segundos donde «buscamos frontend» no +dice nada. `what_you_get` hace explícito el intercambio por trabajo no +remunerado. Lo que la gente contesta a una convocatoria aterriza en el +**buzón**, que es la otra mitad del punto de conversión: sin él la web enseña lo +que hace falta y no recoge a quien se ofrece. + +## Imágenes + +Una imagen dejó de ser una cadena. Un frontend que renderiza en servidor no +puede maquetar bien con una ruta: sin `width` y `height` la página baila +mientras cargan, sin `srcset` cada visitante se descarga el original de 2400px +para un hueco de 400, y sin `alt` no es accesible. Todo eso se sabe aquí, así +que se manda: + +```json +"cover_image": { + "url": "https://admin.example/storage/projects/cover.jpg", + "alt": "Captura del panel de la asociación", + "width": 2400, "height": 1350, "aspect_ratio": 1.7778, + "mime": "image/jpeg", + "placeholder": "data:image/webp;base64,UklGRlIA...", + "srcset": { + "webp": ".../cover-400.webp 400w, .../cover-800.webp 800w, ..." + } +} +``` + +El panel sigue subiendo igual que antes: eliges un archivo y se guarda su ruta. +Lo que cambia es lo que pasa después. + +**La tabla `media` es derivada.** Se identifica por (disco, ruta) —el archivo es +la identidad—, así que dos registros apuntando a la misma subida comparten una +fila y un solo juego de versiones. No se edita a mano: si se borrara entera se +puede reconstruir con `php artisan media:process --all`, que es justo lo que hay +que ejecutar tras cambiar los anchos o añadir un formato. + +**El alt vive en el registro, no en el archivo.** La misma foto puede necesitar +una descripción distinta en un proyecto que en un listado, y solo el contexto +sabe cuál. Por eso `pages.featured_image_alt` y `projects.cover_image_alt` son +columnas traducibles del propio registro. Los avatares de miembros no tienen +campo: el alt de un retrato es el nombre de la persona, y uno que hubiera que +escribir solo se escribiría mal. + +**Nunca se amplía.** Un original de 900px genera las versiones de 400 y 800 y +para. Republicar una imagen pequeña como «1600px» solo produce algo borroso. + +**AVIF se pide, no se supone.** Se genera solo si el PHP que ejecuta sabe +codificarlo; si no, sale WebP y ya. Los vectores y los GIF pasan de largo sin +tocarlos: remuestrear un SVG pierde el sentido y remuestrear un GIF pierde la +animación. Del SVG sí se lee el `viewBox`, porque el hueco hay que reservarlo +igual. + +**Se degrada a propósito.** Una imagen recién subida cuya cola aún no ha corrido +sale con `url` y el resto a `null`: la página pinta un `` normal en vez de +un agujero. + +### El catálogo de medios + +**Administración → Medios** enseña todo lo subido: miniatura, medidas, cuántas +versiones se derivaron, **cuánto pesa contando esas versiones** —el número que +explica adónde se fue el disco— y si algo lo sigue usando. + +Filtros para las cuatro preguntas que no se pueden responder en ningún otro +sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo +que se llevaría `content:prune --media`) y qué **ya no está en disco**. + +No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se +puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que +es justo de lo que se queja la página de salud. El botón solo aparece en los +huérfanos. + +«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la +tabla es derivada, así que reconstruirla siempre es seguro. + +### Galerías + +Un proyecto tiene además una galería —capturas, el antes y el después— que se +edita arrastrando desde su propia pestaña y sale en `gallery`, **solo en el +endpoint de detalle**: un listado de veinte proyectos no necesita todas las +capturas de cada uno. + +Cada imagen lleva dos textos distintos, y la diferencia importa: + +- **`alt`** — qué se ve, para quien no puede verlo. +- **`caption`** — el pie, que se muestra a todo el mundo. «Antes» y «después» + son pies, no descripciones. + +La tabla es polimórfica (`image_attachments`), así que darle galería a las +páginas es añadir una relación `morphMany` y registrar el relation manager; no +hace falta otra tabla. + +```bash +php artisan media:process # lo que la cola no haya hecho +php artisan media:process --all # reprocesar todo +``` + +El scheduler lo ejecuta cada diez minutos, así que un worker caído cuesta un +retraso y no una imagen a medias para siempre. + +**Nota de despliegue:** `MEDIA_DISK` tiene que ser un disco con URL pública +(`public` requiere `php artisan storage:link`). Si tenéis subidas anteriores a +esta versión están en el disco privado `local`: o las movéis a +`storage/app/public` o ponéis `MEDIA_DISK=local`, que se sirve por la ruta +`/storage` de la propia aplicación. + +## Repositorios de git (Gitea) + +Los repositorios viven en **vuestra instancia de Gitea**. Se conectan indicando +solo proveedor y `owner/repo`; todo lo demás —estrellas, lenguajes, +contribuidores, último push, topics— lo trae la sincronización y no es editable +a mano: un contador de estrellas escrito a dedo es una mentira esperando a +ocurrir. + +```dotenv +GITEA_URL=https://git.example.org # raíz de la instancia, sin /api/v1 +GITEA_TOKEN= # solo si hay repos privados +GITEA_ORG=colectivo # permite escribir solo "mi-repo" +``` + +De cada repositorio se replican **solo las issues que sirven para entrar**: las +etiquetadas como *good first issue* o *help wanted* (las variantes aceptadas +están en [`config/git.php`](config/git.php), y el match ignora mayúsculas). +Es lo más eficaz que puede tener una web así, porque convierte a alguien que +pasaba por ahí en contribuidor con un clic. Las que se cierran arriba +desaparecen aquí, para no hacer perder el tiempo a nadie. + +```bash +php artisan repos:sync # encola los que toque refrescar +php artisan repos:sync --sync # en primer plano, con salida legible +php artisan repos:sync colectivo/web --force +``` + +El scheduler lo ejecuta cada hora ([`routes/console.php`](routes/console.php)). +Mantenerlo vivo importa más de lo que parece: **el mayor riesgo de una web así +es parecer abandonada**, y lo que se actualiza solo vale más que la prosa que +nadie va a revisar. El panel marca en rojo los repos que llevan varios intentos +fallando (normalmente renombrados o borrados) y los que llevan medio año sin +un push. + +### Diferencias de Gitea que resuelve el driver + +La API de Gitea se parece a la de GitHub pero no es igual, y lo que cambia está +encapsulado en [`GiteaProvider`](app/Services/Git/GiteaProvider.php): + +- Los contadores se llaman `stars_count` / `forks_count`, no `stargazers_count`. +- Los topics están en su propio endpoint, no en el payload del repositorio. +- Las issues aceptan `type=issues`, así que los pull requests se filtran en el + servidor (en GitHub hay que descartarlos a mano). +- La autenticación usa el esquema `token`, no `Bearer`. +- **No hay endpoint de contribuidores.** El número se deriva de los autores + distintos de los últimos 100 commits. Es una aproximación deliberada: para los + repos de un colectivo pequeño el historial reciente cubre a todo el mundo que + está realmente involucrado, y cuesta una petición en vez de recorrer el log. +- No expone licencia en el payload del repo, así que ese campo queda vacío. + +Sin `GITEA_URL` el proveedor ni siquiera se ofrece en el panel: un driver que no +sabe adónde apuntar solo produciría fallos más tarde. + +El driver de **GitHub** sigue disponible para mirrors o algún repo alojado +fuera. Añadir GitLab es escribir una clase que implemente +[`RepositoryProvider`](app/Services/Git/Contracts/RepositoryProvider.php) y +registrarla en `config/git.php`, sin tocar nada más. + +## API pública (para el frontend) + +Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en +la web, así que pedir un token añadiría ceremonia sin añadir protección. Lo que +**no** está publicado se protege con el secreto de preview. + +``` +GET /api/public/v1/site idiomas + cifras de cabecera +GET /api/public/v1/slugs todas las URLs publicadas, por idioma +GET /api/public/v1/projects[/{slug}] +GET /api/public/v1/members[/{slug}] +GET /api/public/v1/calls[/{slug}] +GET /api/public/v1/pages[/{slug}] +GET /api/public/v1/technologies +GET /api/public/v1/radar +GET /api/public/v1/good-first-issues + +POST /api/public/v1/calls/{slug}/apply responder a una convocatoria +POST /api/public/v1/contact escribir sin convocatoria concreta + +POST /api/public/v1/telemetry/not-found lo que la web no supo resolver +POST /api/public/v1/telemetry/views lo que la web ha mostrado +``` + +Los dos últimos llevan token (`X-Ingest-Token`): los llama el frontend en su +propio nombre, no un visitante. Están descritos en [URLs que nadie +encuentra](#urls-que-nadie-encuentra) y [Qué se lee de +verdad](#qué-se-lee-de-verdad). + +Tres decisiones que conviene conocer: + +**Se resuelve por slug dentro del idioma activo**, no por id, porque es el +patrón de acceso real de un frontend SSR: recibe una URL. Un slug catalán no +resuelve en castellano, así que un mismo registro nunca responde en dos +direcciones. + +**Cada respuesta trae `alternates`**, la ruta del mismo registro en cada idioma +que tenga traducción: + +```json +{ + "es": "/proyectos/mi-web", + "en": "/en/projects/my-web", + "ca": "/ca/projectes/el-meu-web" +} +``` + +Solo esta API sabe que esas tres URLs son la misma cosa, así que sin esto el +frontend no puede emitir `hreflang` correctos y Google trata los tres idiomas +como contenido duplicado. Los idiomas sin traducción se omiten, para no +anunciar una URL que daría 404. + +**El rate limit está pensado para SSR.** Con renderizado en servidor todas las +visitas llegan desde la IP del servidor del frontend; un límite por IP +dimensionado para una persona estrangularía la web entera. De ahí +`SITE_RATE_LIMIT` (600/min por defecto) y `SITE_TRUSTED_IPS` para saltárselo del +todo. + +Las respuestas llevan `ETag`, `Cache-Control: s-maxage`, +`stale-while-revalidate` y `Vary: Accept-Language`. Esta API está en el camino +crítico de cada visita: si va lenta, la web va lenta. El +`stale-while-revalidate` hace que una caída se degrade en contenido algo viejo +en vez de una página rota. + +### El buzón (los únicos endpoints de escritura) + +Hasta aquí la API pública era de solo lectura, así que una convocatoria +terminaba en «esto es lo que necesitamos» sin ningún sitio donde contestar. +Estos dos endpoints cierran ese circuito: + +``` +POST /api/public/v1/calls/{slug}/apply responder a una convocatoria +POST /api/public/v1/contact escribir sin convocatoria concreta +``` + +```bash +curl -X POST https://admin.example/api/public/v1/calls/backend/apply \ + -H 'Content-Type: application/json' -H 'Accept-Language: ca' \ + -d '{"name":"Ada","email":"ada@example.org", + "message":"Tinc unes hores els caps de setmana...", + "hours_per_week":4,"consent":true}' +``` + +Lo que llega aparece en **Colectivo → Buzón**, con la campana del panel +avisando a quien pueda leerlo. Cada solicitud guarda el idioma en que se +escribió, para contestar en el mismo. + +Cinco decisiones que conviene conocer: + +**Solo una convocatoria abierta acepta solicitudes** (404 en cualquier otro +caso). Hacer que alguien redacte un mensaje para un puesto ya cubierto es peor +que no enseñarle formulario. + +**El honeypot archiva, no descarta.** Un envío con el campo trampa relleno se +guarda como `spam` y responde 201 igual que el resto: el bot no aprende nada, y +la persona real a la que el gestor de contraseñas le rellenó el campo oculto se +puede rescatar desde la pestaña de spam. Un mensaje tirado a la basura no. + +**Repetir el envío no duplica.** El mismo email a la misma convocatoria dentro +de la ventana de deduplicación (24 h) responde 200 con `duplicate: true` y no +crea otra fila. Un doble clic en «enviar» no es una segunda persona, y +devolverle un error solo hace que insista. + +**El límite de envíos no es por IP.** Con SSR todos los envíos del sitio llegan +desde la IP del frontend —que además está en `SITE_TRUSTED_IPS`—, así que un +límite por IP no protegería de nada. El presupuesto se gasta **por dirección de +email** (3/min) con un techo global por hora (60) detrás. + +**`consent` es obligatorio.** Son datos personales de alguien que no es usuario +de este sistema, así que el consentimiento viaja en la petición en vez de darse +por supuesto. Si el formulario del frontend no lo envía, la API responde 422. + +El buzón lleva permiso propio (`applications.*`) y **el rol `viewer` no lo +tiene**: leer el contenido de la web y leer los datos de contacto de quien +escribe son dos cosas distintas a propósito. De la IP solo se guarda un HMAC con +la clave de la aplicación, suficiente para reconocer una avalancha y no para +convertir la tabla en un registro de visitantes. + +Si un día se llena de spam, `SITE_ACCEPT_SUBMISSIONS=false` cierra los +formularios sin desplegar; `/site` devuelve `submissions_enabled: false` para +que el frontend los oculte en vez de enseñar un formulario que sería rechazado. + +### Preview de borradores + +```bash +curl -H "X-Preview-Token: $SITE_PREVIEW_TOKEN" \ + https://admin.example/api/public/v1/projects/mi-borrador +``` + +El frontend reenvía el secreto desde su servidor, así que nunca llega al +navegador. Las respuestas de preview se marcan `no-store`. Un +`SITE_PREVIEW_TOKEN` vacío **no** concede acceso a nadie. + +Esto **da por supuesto el renderizado en servidor**: es la única forma de que el +secreto viaje sin acabar en el navegador de cualquiera. Un frontend que +renderizara en cliente necesitaría otra cosa —un token de preview por sesión y +con caducidad—, no este secreto compartido. + +## La vitrina: servicios, clientes, testimonios y charlas + +Cuatro tipos de contenido con los que se compone una landing o un portfolio. +**No son bloques de portada**: aquí no se decide qué secciones hay ni en qué +orden. Cada uno tiene su endpoint y el frontend decide qué monta. + +``` +GET /api/public/v1/services[/{slug}] +GET /api/public/v1/clients +GET /api/public/v1/testimonials?client=…&service=… +GET /api/public/v1/talks +``` + +**Servicios** — el reverso de un proyecto: uno dice lo que se ha hecho, el otro +lo que se puede pedir. Lo que lo hace algo más que una tarjeta bonita es la +relación con proyectos: **un servicio que no puede señalar trabajo real es una +promesa**, y el panel lo marca en naranja cuando lleva cero. Tienen slug por +idioma, `alternates` y entran en `/slugs`, así que el frontend puede darles +página propia. + +**Clientes** — antes eran dos campos de texto (`beneficiary`, +`beneficiary_url`) escritos a mano en cada proyecto: la misma asociación +tecleada dos veces eran dos entidades distintas y el logo no tenía dónde vivir. +Ahora es una tabla, la migración convierte sola lo que ya había, y el muro de +logos sale gratis. Con `is_public`, porque salir en la web de otro es decisión +suya, y sin proyectos publicados detrás no aparece: sería un logo que la web no +puede justificar. + +**Testimonios** — cuelgan de un cliente, un proyecto o un servicio, **no de la +portada**. Así cada página pide los suyos en vez de haber una lista suelta que +alguien tiene que recordar actualizar. + +Y llevan `original_locale`: **traducir una cita es ponerle palabras en la boca a +alguien**. La API manda `is_original: false` cuando lo que estás leyendo lo +hemos traducido nosotros, para que la web pueda decir «traducido del +castellano» en vez de fingir que lo dijo así. + +**Charlas** — el contenido más barato de mantener y el único que demuestra que +el colectivo existe fuera de su propia web. El título **no se traduce**: es el +nombre de una charla que se dio una vez y en un idioma; traducirlo inventaría +una charla que no existió. Solo se traduce el resumen escrito después. Se +escriben una vez y salen a la vez en la landing y en el perfil de quien la dio. + +## Publicación programada y caducidad + +Dos fechas por registro: cuándo entra y cuándo sale. + +``` +published_at con fecha futura, no sale en la web hasta que llegue +unpublish_at al llegar, desaparece sola +``` + +**Lo hacen las consultas, no un job.** Los scopes `published()` leen las dos +fechas, así que la ventana se respeta esté corriendo lo que esté corriendo: un +worker caído no puede devolver a la web el aviso del mes pasado. El scheduler no +es lo que la hace funcionar, es solo lo que mantiene honesto lo que el panel +dice. + +Porque ese es el otro problema: **un panel que pone «Publicada» sobre algo que +la web no está enseñando le está mintiendo a quien lo escribió**. Ahora la +columna de estado dice «Programada · dentro de 3 días» o «Caducada · el 12 de +mayo», con filtros para las dos, y avisa de lo que está a punto de irse. + +```bash +php artisan content:schedule # cierra convocatorias vencidas +php artisan content:schedule --dry-run # dice qué haría +``` + +El scheduler lo ejecuta cada cinco minutos. Cierra las convocatorias cuya fecha +pasó —ya no salían en la web, pero seguían inflando el contador de la barra +lateral, y un contador que miente deja de leerse— y registra en el log lo que +está esperando fecha o lo que acaba de caducar. + +**Lo que a propósito no hace:** tocar el estado de páginas y proyectos. Su +ventana ya las oculta, y reescribir un estado tiraría el que alguien eligió para +grabar algo que las fechas ya dicen. En un proyecto, además, el estado es su +ciclo de vida («en marcha», «en pausa»), que no es lo mismo que si se ve o no: +por eso la visibilidad tiene columna propia en la tabla en vez de pisar la otra. + +`unpublish_at` viaja también en la API pública, para que una caché de más abajo +pueda caducar a la vez que el contenido en vez de quedarse con un aviso que ya +no es cierto. + +## Redirecciones + +Cambiar un slug rompe todos los enlaces compartidos, todos los marcadores y +todos los resultados de búsqueda — **en silencio**, porque aquí no se entera +nadie y la web solo lo descubre cuando un visitante recibe un 404. + +El momento en que el slug cambia es el único en que la dirección vieja todavía +se conoce, así que es cuando se apunta: + +``` +GET /api/public/v1/redirects todas las reglas activas, de una vez +``` + +Se crean solas para páginas, proyectos, convocatorias y servicios, **una por +idioma** —la URL catalana tiene otro prefijo y otro segmento, así que una regla +nunca podría cubrir las dos—. Y no encadenan: si algo se renombra dos veces, +las dos direcciones viejas apuntan a la actual, no una a otra. Renombrar de +vuelta al slug original borra la regla en vez de crear un bucle. + +En **Administración → Redirecciones** se pueden añadir a mano las de enlaces +anteriores a esta web. La columna de usos dice si alguien sigue viniendo por +ahí, que es lo que decide si una regla se puede borrar algún día. + +Dos comprobaciones vigilan lo que el propio panel escribe: una regla que +**apunta a sí misma o a otra regla** (el navegador da vueltas o paga saltos de +más) y una que **lleva a una página que ya no existe**, que es peor que el 404 +que venía a evitar — pasa cuando se despublica lo que motivó la regla, que es +algo perfectamente normal y no deja rastro en ningún otro sitio. + +### URLs que nadie encuentra + +Todo lo anterior tiene un punto ciego: **las redirecciones solo se escriben +desde este lado**. Cubren los slugs que se renombran aquí, que es de lo único +que esta aplicación se entera. Los enlaces de la web anterior, una dirección +impresa en un papel, el enlace que otro sitio puso hace tres años a una página +que ya se movió — todo eso falla en la web, delante de un visitante, y aquí no +se sabe nunca. + +Así que lo cuenta el frontend: + +``` +POST /api/public/v1/telemetry/not-found lo que no ha sabido resolver +``` + +```bash +curl -X POST https://admin.example/api/public/v1/telemetry/not-found \ + -H "X-Ingest-Token: $SITE_INGEST_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"path":"/proyectos/nombre-viejo","locale":"es", + "referrer":"https://otro-sitio.example/articulo"}' +``` + +Aparecen en **Administración → URLs no encontradas**, ordenadas por número de +visitas, y cada fila tiene el único botón que importa: **crear la redirección**, +con la dirección rota ya puesta. Solo hay que decir a dónde debería llevar. La +regla se escribe en la lista de al lado y la entrada deja de estar pendiente. + +Seis decisiones que conviene conocer: + +**Lleva token, a diferencia del resto de la API pública.** Los endpoints de +lectura no lo necesitan porque devuelven lo que ya está publicado, y el buzón +lo escribe un visitante rellenando un formulario. Este lo llama el frontend en +su propio nombre, así que puede guardar un secreto — y un endpoint de escritura +sin autenticar es una forma de llenar una tabla con lo que a cualquiera se le +ocurra. `SITE_INGEST_TOKEN` vacío **desactiva** el endpoint; no lo abre. + +**Es un contador, no un registro.** Una fila por (dirección, idioma) con el +número de visitas. Un rastreador recorriendo una sección muerta cuesta una fila, +no una por petición, y ese número es justo lo que decide si una dirección merece +redirección. Acepta lotes de hasta 50. + +**Las direcciones se normalizan igual que en las redirecciones.** `/algo/` y +`/algo?utm_source=x` son la misma dirección rota. Si las dos normalizaciones se +separaran, la lista se llenaría de direcciones que ya tienen regla. + +**Se tira lo que nunca estuvo aquí.** `/wp-admin`, `/.env`, cualquier cosa +acabada en `.php`: casi todo lo que se le pide a una web pública es alguien +buscando el panel de otro, y una lista donde los enlaces rotos de verdad están +enterrados debajo de eso es una lista que no abre nadie. La respuesta dice +cuántos reportes se guardaron de los que llegaron, para que el frontend pueda +comparar su filtro con este. + +**La procedencia es la columna que más dice.** Si el `referrer` es de nuestro +propio dominio, no es el marcador viejo de alguien: es que **algo que +publicamos enlaza a una dirección que no existe**. El panel lo marca en rojo y +el check de salud lo sube de aviso a fallo. + +**Ignorar y resolver no son lo mismo cuando la dirección vuelve.** Una resuelta +que se vuelve a pedir regresa a pendiente —la redirección se borró, o nunca +llegó a funcionar—; una ignorada se queda ignorada por muchas visitas que reciba, +porque si no el botón no serviría dos veces. + +El check **URLs que nadie encuentra** cuenta las pendientes con más de una +visita: una sola petición es tan probable que sea un escáner como una persona, y +un número que no llega nunca a cero deja de leerse. `content:prune` se lleva las +de una sola visita que llevan 30 días sin repetirse; las ignoradas y las +resueltas se quedan, porque olvidarlas traería la misma decisión otra vez la +semana siguiente. + +### Qué se lee de verdad + +El panel siempre ha sabido qué existe y qué está publicado. Nunca ha sabido si +alguien lo abre — y esa es la mitad que falta de casi cualquier decisión +editorial: **qué merece traducirse primero**, si el proyecto destacado lo mira +alguien, si una página compensa el trabajo de mantenerla al día. + +``` +POST /api/public/v1/telemetry/views lo que la web ha mostrado +``` + +```bash +curl -X POST https://admin.example/api/public/v1/telemetry/views \ + -H "X-Ingest-Token: $SITE_INGEST_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"views":[{"type":"project","slug":"mi-web","locale":"es","count":12}, + {"type":"page","slug":"quienes-somos","locale":"ca"}]}' +``` + +**Esto no es analítica, y la diferencia importa.** No hay visitante aquí ni +forma de construir uno: ni IP, ni sesión, ni user agent, ni referrer, ni hora +del día. Lo que se guarda es **una fila por registro, idioma y día con un +número** — justo lo que hacen falta para las preguntas de arriba y nada que se +pueda volver a convertir en una persona. La tabla no tiene dónde meterlo, y hay +un test que lo comprueba. + +Cuatro decisiones más: + +**Se reporta tipo y slug, no una ruta.** La web pidió +`/public/v1/projects/mi-web` para pintar la página, así que ya sabe qué era; no +tiene que volver a parsear su propia URL. Y así un reporte solo puede nombrar +algo que esta API sirve. + +**Cada idioma cuenta aparte.** Saber en qué idioma se lee algo es la mitad del +valor: es lo que dice si la traducción catalana la usa alguien. + +**Un slug que ya no resuelve no cuenta.** Eso es un 404, y para eso está el +endpoint de al lado. Sumarlo aquí sería atribuir una lectura a nada. + +**El `count` está acotado a 1000 por reporte.** La web cuenta sus propios +renders; no le corresponde inventarse una audiencia. + +En el panel salen como columna **Visitas** (30 días, sumando idiomas) en +páginas, proyectos y servicios, con filtro **«Publicado y sin visitas»** — que +es la pregunta accionable— y un widget de **lo más leído** en el dashboard, que +no aparece hasta que hay algo que enseñar: un widget con tres ceros enseña a +ignorar esa esquina de la pantalla. + +La columna está **vacía**, no a cero, cuando no hay datos: mientras el frontend +no reporte, un cero sería mentira. `content:prune` conserva 400 días +(`--views-days`), que da para comparar con la primavera pasada. + +## Traducciones que se quedaron atrás + +El check de traducciones que faltan encuentra el agujero: publicado en +castellano, ausente en catalán. **No encuentra el caso que de verdad pasa**: la +traducción existe —así que ninguna pantalla se queja y el registro cuenta como +terminado— y lleva dos meses diciendo algo que el original dejó de decir. Una +traducción desactualizada es peor que una que falta, porque la que falta al +menos es honesta. + +No hace falta ninguna columna nueva para saberlo: **el historial ya lo sabe**. +Cada revisión guarda el mapa de idiomas entero del campo que tocó, antes y +después, así que «cuándo cambió por última vez el cuerpo en castellano y cuándo +en catalán» es una pregunta que ya se puede responder. Una columna sería una +segunda copia de eso, que alguien tendría que mantener a mano. + +Se ve donde ya se miraba, en la columna de idiomas: + +``` +ES EN CA las tres al día +ES EN ca catalán sin traducir +ES EN CA! catalán traducido y desactualizado +``` + +Con filtro **«Traducción desactualizada»** en páginas, proyectos y servicios, y +un check de salud que dice desde cuándo lleva a la deriva cada idioma —«desde +marzo» es lo que decide si corre prisa. + +Tres decisiones: + +**El slug no cuenta.** Renombrar la dirección castellana no hace menos cierto el +texto catalán, y es el único campo traducible que cambia solo constantemente: +contarlo marcaría media web como desactualizada la primera vez que alguien +ordena una URL. + +**Un idioma vacío no está desactualizado, está sin traducir**, y de eso ya +informa el otro check. Decir las dos cosas del mismo registro sería tener dos +listas que no se vacían nunca. + +**Un registro que nadie ha editado nunca está al día**, se escribiera en uno o +en tres idiomas: si llegaron juntos, ninguno va por detrás de otro. + +El cálculo se cachea contra el `updated_at` del registro, así que se rehace en +cuanto algo cambia y ni un minuto antes. + +## Revisión + +`pages.publish` lleva separado desde el principio: **poner algo en la web es un +acto mayor que escribirlo**. Lo que faltaba era la otra mitad. Quien tenía +`pages.update` y no `publish` podía terminar un texto y **no tenía forma de +decir que estaba listo** — el campo de estado que lo diría está deshabilitado +justo para esos usuarios. El traspaso ocurría por chat, o no ocurría. + +Ahora son dos botones, en páginas, proyectos y servicios: + +**Pedir revisión** — para quien no puede publicar. Opcionalmente se lo pide a +alguien concreto; si no, lo ve todo el que pueda publicarlo. Puede dejar una +nota. No hace falta ningún permiso nuevo: **poder editar algo ya es el derecho a +decir que has terminado con ello**. + +**Devolver** — para quien sí puede publicar, cuando todavía no. El motivo es +**obligatorio**: devolver el trabajo de alguien sin decir por qué es lo que hace +que la gente deje de usar la revisión y publique por su cuenta. + +No hay botón de «aprobar», porque ya existía: **publicar es la aprobación**. Al +publicar se cierra la revisión sola, venga del formulario, del botón de la tabla +o de donde sea — está enganchado al estado y no al botón, así que nada puede +dejar algo en la web y a la vez marcado como pendiente. Programar para dentro de +una semana **no** la cierra: la web todavía no lo enseña. + +La pestaña **Revisión** de cada ficha guarda la conversación: qué se pidió, qué +se devolvió, por qué, y quién publicó al final. Es lo que el historial de +cambios no puede guardar — una revisión sabe que el título antes decía otra +cosa, pero no puede saber que esto volvió porque falta la foto, que es la frase +que nadie escribió en ningún campo. + +Alrededor de eso: + +- **La campana** avisa a quien puede publicarlo. Al devolver, avisa a quien lo + pidió y **solo** a esa persona: una cola que anuncia cada decisión a todo el + mundo es una campana que se deja de leer. +- **El dashboard** dice cuántas cosas esperan tu revisión. La campana se marca + como leída y desaparece; esto no. +- **Filtro «Esperando mi revisión»** en las tres tablas. Las peticiones sin + destinatario salen en la de todos: una revisión que no se pidió a nadie es de + todos, que sigue siendo mejor que una dirigida a quien el autor adivinó que + estaba libre. +- **Check de salud** para lo que lleva más de una semana esperando. Es el fallo + que introduce este paso: antes, quien no podía publicar estaba atascado y lo + sabía; ahora está esperando y cree que algo está pasando. + +**Solo en páginas, proyectos y servicios**, que son los tres con permiso de +publicación propio. Añadir revisión a un testimonio sería ceremonia: quien puede +escribirlo puede publicarlo, así que no hay a quién preguntar. + +> **Esto solo se activa para quien pueda editar y no publicar**, que es el rol +> **writer**. `editor` tiene todas las habilidades de contenido, `publish` +> incluida, así que a un editor los botones no le salen: no los necesita. +> Después de actualizar, `php artisan permissions:sync` crea el rol nuevo. + +### En lote + +Los dos botones de revisión existen también para una selección entera: +**Pedir revisión** y **Publicar**, en la barra de acciones en lote de páginas, +proyectos y servicios. + +Ninguno de los dos trata la selección como un bloque. **Publicar** salta lo que +ya está publicado; **Pedir revisión** salta lo que ya espera revisión o ya está +publicado — una selección de veinte filas en estados mezclados es el caso +normal, no uno raro que haya que evitar. El aviso final dice cuántas se tocaron +de verdad, no cuántas se marcaron. + +Los registros se guardan **uno a uno, no en una sola consulta**: publicar +dispara el cierre de la revisión pendiente y la entrada en el historial de +cambios, y una única `UPDATE` sobre toda la selección se saltaría las dos cosas +sin avisar — el mismo motivo por el que `content:prune` ya recorre la papelera +registro a registro en vez de en bloque. + +La visibilidad de los botones se decide **una vez, por el permiso** +(`pages.publish`, no una comprobación registro a registro): a diferencia de +`update`, que en páginas tiene una excepción por autoría, publicar en lote no +tiene ese matiz, y preguntárselo a la política de un modelo sin un registro +concreto no tiene sentido. + +## Ediciones simultáneas + +Dos personas pueden abrir la misma ficha a la vez, y hasta ahora el panel no +tenía ni idea. Quien guardaba segundo pisaba a quien guardaba primero **sin +avisar a nadie**: sin error, sin rastro más allá de una revisión que parece una +edición cualquiera. + +Dos avisos, uno por cada momento en que la colisión se puede notar: + +**Al abrir la ficha**, si alguien más la tiene abierta ahora mismo, se dice +quién. Solo se avisa a quien llega segundo — quien ya estaba no tiene por qué +esperar compañía, y no hay nada que este aviso pudiera decirle que le sirviera +de algo. + +**Al guardar**, si el registro cambió por debajo mientras se editaba, **se +rechaza el guardado**. Este es el que de verdad evita perder trabajo, y no +depende de que el aviso anterior se viera ni de que llegara a mostrarse: una +pestaña cerrada, un segundo monitor, alguien que guardó hace diez minutos, todo +acaba atrapado igual, porque se comprueba la base de datos en el momento de +guardar, no lo que decía un aviso anterior. Cuando el historial de la ficha +sabe quién fue, el mensaje lo nombra. + +``` +Alguien más lo tiene abierto al entrar, si aplica +No se ha guardado — María lo cambió al guardar, si aplica +``` + +Aplicado a **las dieciséis pantallas de edición del panel**, no elegido recurso +por recurso: una colisión es cosa de dos personas y un registro, no de qué sea +el registro. + +Tres decisiones: + +**Quién ha «entrado» se olvida solo a los 10 minutos** sin interacción. No hay +forma de saber que alguien cerró la pestaña, así que la presencia es en +realidad «esto se abrió hace poco y alguien sigue tecleando en ello» — la +forma correcta de plantear el problema: importa que dos personas estén +editando a la vez, no un recuento exacto. + +**La comprobación es de segundo entero**, porque así se guarda `updated_at` +aquí. Dos guardados dentro del mismo segundo son una carrera real incluso en +producción; el aviso es honesto en que no la detecta. + +**El valor de referencia viaja bloqueado.** Livewire solo conserva entre +peticiones las propiedades públicas de un componente — una protegida se +reiniciaría a `null` en cada guardado y desactivaría el aviso en silencio, que +es exactamente el fallo que apareció al escribir esto—, y pública a secas +podría venir manipulada por la propia petición. Va marcada `#[Locked]`, igual +que el registro que edita. + +## Historial de cambios + +Cada modelo de contenido guarda quién cambió qué, con el valor de antes al lado +del de después. Solo lo que cambió: guardar registros enteros haría el historial +más grande que el contenido que describe. + +Lo que más se echaba en falta es **quién publicó**. `pages.publish` existe como +permiso aparte precisamente porque poner algo en la web es un acto mayor que +escribirlo, y no quedaba constancia; ahora publicar y despublicar salen con +nombre propio en la lista, distintos de un «editado» cualquiera. + +Se ve en la pestaña **Historial** de cada ficha, con el diff campo por campo y +un botón para volver a esos valores. Restaurar también queda registrado, así que +nunca se pierde el camino de vuelta. + +Un cambio hecho por el scheduler o por un comando se registra **sin usuario** y +lo dice, en vez de echarle la culpa al último que entró. + +## Salud del sitio + +La web está en otro repositorio y en otra máquina, así que sus fallos no se ven +editando: se ven visitándola, normalmente tarde y normalmente los ve un +visitante. **Administración → Salud del sitio** los junta en una página, con un +contador en la barra lateral. + +Trece comprobaciones, ordenadas por gravedad: + +| | Comprueba | Por qué importa | +|---|---|---| +| 🔴 | Slugs que faltan | Traducido a un idioma sin slug en él: esa URL no existe o da 404, y el `hreflang` sale mal. | +| 🔴 | Slugs repetidos | Dos registros en la misma URL. La regla lo impide al escribir, pero un slug vive dentro de un JSON y **nunca puede ser una restricción de base de datos**: un seeder o una importación se cuelan. | +| 🔴 | Imágenes rotas | El archivo ya no está en disco. La API sigue publicando la URL porque no tiene motivo para dudar. | +| 🔴 | Gente esperando respuesta | Alguien escribió hace más de una semana y sigue sin contestación. | +| 🔴 | URLs que nadie encuentra | Direcciones que dan 404 y se piden más de una vez. En rojo cuando el enlace roto sale de nuestra propia web; en amarillo cuando es de fuera. | +| 🟡 | Contenido sin traducir | Publicado pero ausente en algún idioma. | +| 🟡 | Traducciones desactualizadas | El original cambió después de traducirse. La traducción existe, así que nada más lo avisa. | +| 🟡 | Imágenes sin describir | Sin `alt`. La API manda `alt: ""`, que es la forma correcta de decir «decorativa», así que el frontend no distingue el blanco deliberado del olvidado. Esto sí. | +| 🟡 | Fichas incompletas | Sin meta descripción o sin portada: nada que enseñar en un buscador ni al pegar el enlace en un chat. | +| 🟡 | Revisiones sin contestar | Alguien pidió revisión hace más de una semana. Antes estaba atascado y lo sabía; ahora espera y cree que algo está pasando. | +| 🟡 | Convocatorias vencidas | Abiertas con la fecha pasada. Ya no salen en la web, pero inflan el contador del panel. | +| 🟡 | Imágenes sin procesar | Más de una hora en cola: no hay worker ni scheduler. | +| 🟡 | Repositorios que no sincronizan | La web sigue enseñando los últimos números, que ya no son ciertos. | + +Cada resultado trae **ejemplos, no solo un número**: «12 páginas» es una +estadística, «12, empezando por estas» es algo sobre lo que actuar. + +**Los checks se ocultan según permisos.** Contar solicitudes es saber que +existen, así que quién puede verlo es la misma pregunta que quién puede leer el +buzón; el `viewer` no ve ese check ni el de repositorios. El comando, en cambio, +los ejecuta todos: un despliegue no es una persona a quien esconderle nada. + +```bash +php artisan site:check # falla si hay avisos o fallos +php artisan site:check --fail-on=error # falla solo con lo que rompe la web +php artisan site:check --fail-on=never # solo informa +php artisan site:check --json +``` + +Sale con código distinto de cero, así que un despliegue puede negarse a +terminar sobre una web que ya está rota. + +**El contador de la barra lateral está cacheado cinco minutos**, porque se +dibuja en cada página del panel y ejecutar diez comprobaciones para pintar un +número haría el panel entero más lento. Abrir la página lo invalida: es el +momento en que sí queremos respuestas frescas. + +Añadir una comprobación es escribir una clase que implemente +[`Check`](app/Support/Health/Check.php) y listarla en `HealthReport::CHECKS`. + +### Herramientas del día a día + +**Dashboard** — arriba del todo, lo que está esperando a alguien: gente sin +respuesta, contenido que caduca, servicios sin obra detrás. Son las mismas +comprobaciones de salud en el sitio donde todo el mundo aterriza, en lugar de +contadores que no cambian nunca. + +**Papelera** (Administración → Papelera) — todo lo borrado junto, con el botón +de deshacer al lado. Antes recuperar algo exigía acordarse de en qué recurso +estaba y activar un filtro, que es mucho pedirle a quien acaba de borrar lo que +no era. + +**Vista previa** — un enlace firmado por registro y por idioma, que caduca en 8 +horas y **solo abre ese registro**. El `SITE_PREVIEW_TOKEN` compartido sigue +funcionando, pero es un secreto único que hay que pegar en un curl; esto se +puede mandar por chat sin dar acceso a todos los borradores del sitio. + +**Duplicar** — en páginas, proyectos, servicios y convocatorias. Copia todo +menos el slug y las fechas, porque dos registros no pueden responder en la misma +URL. + +**Búsqueda global** — los doce recursos, desde la barra de arriba. + +### Versión de contenido + +`/site` devuelve `content_version`, un número que sube cuando cambia algo +**publicado**. Úsalo como parte de la clave de caché del frontend y todo lo que +hay por debajo caduca en el momento en que la web cambió de verdad, en vez de cuando +lo dice un temporizador. Sin webhook de purga, sin secreto compartido y sin nada +que desplegar. + +Editar un borrador no lo mueve: nadie lo está viendo. Despublicar sí, porque +desaparecer es tan visible como aparecer. + +### Limpieza + +Cinco cosas se acumulan para siempre y ninguna se ve: el historial, la +papelera, los archivos de imagen cuyo registro ya no existe y las dos que +reporta el frontend, los 404 y las cifras de lectura. Por separado son insignificantes; en un par de años son la +mayor parte de la base de datos y del disco. + +```bash +php artisan content:prune --dry-run # dice qué se iría +php artisan content:prune # historial > 1 año y papelera > 90 días +php artisan content:prune --media # además, imágenes que no usa nadie +``` + +Lo que reporta el frontend —los 404 y las cifras de lectura— es lo único que +crece sin que nadie del colectivo haga nada, así que se limpia por defecto en +vez de detrás de un flag: de los 404 solo se van los de una sola visita que +llevan 30 días sin repetirse (`--missing-days`), y de las lecturas lo anterior a +400 días (`--views-days`). + +Es deliberadamente conservador: **nunca corre solo** —no está en el scheduler—, +las ventanas son generosas, y del historial de cada registro **siempre se +conservan la primera y la última entrada**, que son los dos datos que alguien +pregunta años después: cuándo apareció y quién lo tocó por última vez. + +Los archivos van aparte con `--media` porque borrar uno es lo único aquí que no +se arregla restaurando una fila. Una imagen de algo que está en la papelera **no +se toca**: se puede recuperar, y recuperarlo con la imagen rota sería peor. + +## Modo mantenimiento + +Pone en mantenimiento **solo la web pública**. Deliberadamente *no* es +`artisan down`, que tumbaría también el panel justo cuando hace falta para +arreglar lo que se ha roto. + +Mientras está activo: + +- La API pública responde **503** con el aviso traducido, `Retry-After` y + `Cache-Control: no-store` (un 503 cacheado sobreviviría al mantenimiento). +- El panel y la API de administración **siguen funcionando**: se puede seguir + editando y los repositorios se siguen sincronizando. +- El equipo ve la web real usando el token de preview o desde una IP de + `SITE_TRUSTED_IPS`, así que se puede comprobar el arreglo antes de levantarlo. + +Desde el panel: **Administración → Modo mantenimiento**, con el aviso editable +en los tres idiomas y una hora de levantado automático opcional (para que un +mantenimiento olvidado no deje la web caída indefinidamente). Mientras está +activo, la barra lateral lo marca en rojo. + +Desde la CLI, para scripts de despliegue: + +```bash +php artisan site:maintenance --message="Volvemos en un rato" --until="+2 hours" +php artisan site:maintenance --status +php artisan site:maintenance --up +``` + +El cuerpo del 503 es lo que el frontend renderiza como página de mantenimiento: + +```json +{ + "message": "Estamos migrando el Gitea. Volvemos en un rato.", + "maintenance": true, + "locale": "es", + "since": "2026-08-13T10:01:13+00:00", + "until": "2026-08-13T12:01:13+00:00" +} +``` + +Activarlo es un permiso propio (`settings.maintenance`), separado de editar +ajustes: los editores ven el estado pero no pueden tumbar la web. + +## API REST de administración + +Documentada junto con la pública en [`public/openapi.yaml`](public/openapi.yaml) +(OpenAPI 3.0), navegable en **`/docs/api`**. + +``` +POST /api/v1/auth/token canjear credenciales por un token +GET /api/v1/me quién soy + mis permisos efectivos +GET /api/v1/pages filtros: status, category_id, search, + translated_in, trashed, sort, per_page +POST /api/v1/pages +GET /api/v1/pages/{page} +PATCH /api/v1/pages/{page} +DELETE /api/v1/pages/{page} +POST /api/v1/pages/{page}/restore + /api/v1/categories CRUD completo +GET /api/v1/users PATCH para roles, is_active, locale +GET /api/v1/roles solo lectura +GET /api/v1/roles/catalogue catálogo de permisos agrupado +``` + +`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del +cliente en vez de comprobar nombres de rol. + +Quien no tenga `pages.publish` solo ve páginas publicadas en el listado. + +## Documentación navegable + +``` +/docs/api Swagger UI con las dos APIs +/openapi.yaml la especificación en crudo +/api-types.d.ts los tipos TypeScript, generados de la anterior +``` + +En el panel está en **Administración → Documentación de la API**, y abre en una +pestaña nueva: ir a mirar la referencia no debería tirar por la borda lo que +tuvieras a medio escribir. + +**Sin autenticación, a propósito**: quien más la necesita escribe el frontend y +no tiene por qué tener cuenta en el panel, y la especificación describe +endpoints, no secretos. Para reservarla al colectivo, envuelve la ruta en el +middleware `auth` y saca `openapi.yaml` de `public/`. + +Swagger UI está **autoalojado** en `public/vendor/swagger-ui/`, no servido desde +un CDN: la documentación tiene que funcionar en un portátil sin internet, y una +etiqueta ` + + + + diff --git a/resources/views/filament/pages/site-health.blade.php b/resources/views/filament/pages/site-health.blade.php new file mode 100644 index 0000000..1e0c77d --- /dev/null +++ b/resources/views/filament/pages/site-health.blade.php @@ -0,0 +1,138 @@ + + {{-- The headline. Whether anything is actually wrong is the one thing + somebody opening this page came to find out. --}} + @if ($this->problems()->isEmpty()) +
+
+ + +

+ {{ __('health.all_clear') }} +

+
+
+ @else + @php($tone = $this->hasErrors() ? 'danger' : 'warning') + +
$tone === 'danger', + 'border-warning-300 bg-warning-50 dark:border-warning-700/50 dark:bg-warning-500/10' => $tone === 'warning', + ])> +
+ $tone === 'danger', + 'text-warning-600 dark:text-warning-400' => $tone === 'warning', + ]) + /> + +
+

$tone === 'danger', + 'text-warning-800 dark:text-warning-300' => $tone === 'warning', + ])> + {{ trans_choice('health.problem_summary', $this->problems()->count(), ['count' => $this->problems()->count()]) }} +

+ +

$tone === 'danger', + 'text-warning-700 dark:text-warning-400' => $tone === 'warning', + ])> + {{ __('health.problem_hint') }} +

+
+
+
+ @endif + + {{-- Failing checks, worst first. --}} + @foreach ($this->problems() as $finding) + + + + $finding->severity->getColor() === 'danger', + 'text-warning-500' => $finding->severity->getColor() === 'warning', + ]) + /> + + {{ $finding->title() }} + + + {{ $finding->count }} + + + + +
+

+ {{ $finding->description() }} +

+ + {{-- Examples rather than only a number: "12 pages" is a + statistic, "12, starting with these" is something + somebody can act on. --}} + @if (filled($finding->samples)) +
    + @foreach ($finding->samples as $sample) +
  • + + {{ $sample }} +
  • + @endforeach + + @if ($finding->count > count($finding->samples)) +
  • + {{ __('health.and_more', ['count' => $finding->count - count($finding->samples)]) }} +
  • + @endif +
+ @endif + + @if ($finding->url) + + {{ __('health.actions.go_fix') }} + + @endif +
+
+ @endforeach + + {{-- What passed, collapsed. Worth showing at all: it says what was + actually looked at, so a clean page is not mistaken for a page that + never ran. --}} + @if ($this->passing()->isNotEmpty()) + + + {{ trans_choice('health.passing', $this->passing()->count(), ['count' => $this->passing()->count()]) }} + + +
    + @foreach ($this->passing() as $finding) +
  • + + + + {{ $finding->title() }} + — {{ $finding->description() }} + +
  • + @endforeach +
+
+ @endif +
diff --git a/resources/views/filament/pages/site-maintenance.blade.php b/resources/views/filament/pages/site-maintenance.blade.php new file mode 100644 index 0000000..89a32ed --- /dev/null +++ b/resources/views/filament/pages/site-maintenance.blade.php @@ -0,0 +1,59 @@ + + {{-- Current state, stated plainly. Whether the public site is down is the + one thing this page must never leave ambiguous. --}} + @if ($this->isEnabled()) +
+
+ + +
+

+ {{ __('maintenance.status_down') }} +

+ +

+ @if ($this->startedAt()) + {{ __('maintenance.since', ['time' => $this->startedAt()]) }} + @endif + + @if ($this->endsAt()) + · {{ __('maintenance.lifts_at', ['time' => $this->endsAt()]) }} + @endif +

+ +

+ {{ __('maintenance.panel_unaffected') }} +

+
+
+
+ @else +
+
+ + +

+ {{ __('maintenance.status_up') }} +

+
+
+ @endif + + {{ $this->form }} + +
+

{{ __('maintenance.bypass_hint') }}

+
+ + @unless ($this->canToggle()) +
+ {{ __('maintenance.no_permission') }} +
+ @endunless +
diff --git a/resources/views/filament/pages/trash.blade.php b/resources/views/filament/pages/trash.blade.php new file mode 100644 index 0000000..7680ed6 --- /dev/null +++ b/resources/views/filament/pages/trash.blade.php @@ -0,0 +1,57 @@ + + @php($groups = $this->groups()) + + @if ($groups->isEmpty()) +
+

+ {{ __('panel.trash.empty') }} +

+
+ @else + @foreach ($groups as $group) + + + {{ $group['records']->count() }} + + +
    + @foreach ($group['records'] as $record) +
  • +
    +

    + {{ $this->labelFor($record) }} +

    +

    + {{ __('panel.trash.deleted_at', ['when' => $record->deleted_at?->diffForHumans()]) }} +

    +
    + +
    + + {{ __('panel.trash.restore') }} + + + {{-- Erasing is the one thing here that cannot be + undone, so it asks first. --}} + + {{ __('panel.trash.erase') }} + +
    +
  • + @endforeach +
+
+ @endforeach + @endif +
diff --git a/resources/views/filament/partials/environment-banner.blade.php b/resources/views/filament/partials/environment-banner.blade.php new file mode 100644 index 0000000..4e23286 --- /dev/null +++ b/resources/views/filament/partials/environment-banner.blade.php @@ -0,0 +1,22 @@ +{{-- Rendered at the very top of every page in the panel, on every install. + + The failure this exists for runs in one direction only: mistaking a real + deployment for a throwaway copy, not the other way round. Nobody edits a + local clone carefully because they think it is production — the mistake + that costs something is the reverse. So `local` stays silent, and + anything else says so, loudly in the one case that matters. --}} + +@unless (app()->environment('local')) +
app()->environment('production'), + 'bg-warning-600' => ! app()->environment('production'), + ]) + > + {{ app()->environment('production') + ? __('panel.environment.production') + : __('panel.environment.other', ['name' => app()->environment()]) }} +
+@endunless diff --git a/resources/views/filament/partials/language-switcher.blade.php b/resources/views/filament/partials/language-switcher.blade.php new file mode 100644 index 0000000..607ecd7 --- /dev/null +++ b/resources/views/filament/partials/language-switcher.blade.php @@ -0,0 +1,30 @@ +@php + $locales = config('locales.supported'); + $current = app()->getLocale(); +@endphp + + + + + + + + @foreach ($locales as $code => $meta) + + {{ $meta['flag'] }} {{ $meta['native'] }} + + @endforeach + + diff --git a/resources/views/filament/partials/login-footer.blade.php b/resources/views/filament/partials/login-footer.blade.php new file mode 100644 index 0000000..0734119 --- /dev/null +++ b/resources/views/filament/partials/login-footer.blade.php @@ -0,0 +1,30 @@ +{{-- Rendered under the sign-in form. + + Two things belong here and nowhere else: whatever the identity server had + to say about a redirect that failed (those routes still exist), and the + language picker — the one place it is needed before there is a user whose + preference we could read. --}} + +@if (session('error')) + +@endif + + diff --git a/resources/views/filament/partials/preview-links.blade.php b/resources/views/filament/partials/preview-links.blade.php new file mode 100644 index 0000000..056eef6 --- /dev/null +++ b/resources/views/filament/partials/preview-links.blade.php @@ -0,0 +1,23 @@ +{{-- One link per language: a draft translated into two has two addresses, and + whoever is reviewing the Catalan one needs that one. --}} +
+ @foreach ($links as $language => $url) +
+

{{ $language }}

+ +
+ + + + {{ __('panel.preview.open') }} + +
+
+ @endforeach +
diff --git a/resources/views/filament/revisions/diff.blade.php b/resources/views/filament/revisions/diff.blade.php new file mode 100644 index 0000000..1f80a91 --- /dev/null +++ b/resources/views/filament/revisions/diff.blade.php @@ -0,0 +1,44 @@ +@php + /** @var \App\Models\Revision $revision */ + $render = function ($value): string { + if ($value === null || $value === '') { + return '—'; + } + + return is_scalar($value) + ? (string) $value + : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + }; +@endphp + +{{-- Old beside new, field by field. Reading the difference is the point: + "changed the Catalan title" is useful, "saved the page" is not. --}} +
+ @forelse ($revision->fields() as $field) + @php($diff = $revision->diff($field)) + +
+

{{ $field }}

+ +
+
+

+ {{ __('panel.revisions.before') }} +

+
{{ $render($diff['old']) }}
+
+ +
+

+ {{ __('panel.revisions.after') }} +

+
{{ $render($diff['new']) }}
+
+
+
+ @empty +

+ {{ __('panel.revisions.no_changes') }} +

+ @endforelse +
diff --git a/resources/views/filament/tables/columns/json-value.blade.php b/resources/views/filament/tables/columns/json-value.blade.php new file mode 100644 index 0000000..f75e3e6 --- /dev/null +++ b/resources/views/filament/tables/columns/json-value.blade.php @@ -0,0 +1,8 @@ +@php + $json = json_encode($getState(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); +@endphp + +{{-- A setting's value is arbitrary JSON, so it is shown as-is rather than + coerced into a string. Bounded on both axes: one long value must not push + the table sideways or turn the row into a screenful. --}} +
{{ $json }}
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..26e294a --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

With so many options available to you,
we suggest you start with the following:

+ + + +

+ v{{ app()->version() }} + + View changelog + + + + +

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..b78cb70 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,175 @@ +name('api.public.') + ->middleware(['throttle:public-api', 'preview', 'site.available', 'cache.public']) + ->group(function () { + Route::get('site', [SiteController::class, 'index'])->name('site'); + Route::get('slugs', [SiteController::class, 'slugs'])->name('slugs'); + Route::get('radar', [SiteController::class, 'radar'])->name('radar'); + Route::get('technologies', [SiteController::class, 'technologies'])->name('technologies'); + Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues'); + Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects'); + + Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index'); + Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show'); + + Route::get('projects', [PublicProjectController::class, 'index'])->name('projects.index'); + Route::get('projects/{slug}', [PublicProjectController::class, 'show'])->name('projects.show'); + + Route::get('members', [PublicMemberController::class, 'index'])->name('members.index'); + Route::get('members/{slug}', [PublicMemberController::class, 'show'])->name('members.show'); + + Route::get('calls', [PublicCallController::class, 'index'])->name('calls.index'); + Route::get('calls/{slug}', [PublicCallController::class, 'show'])->name('calls.show'); + + // The pieces a landing page is made of. Four collections rather than a + // "home page" payload: what gets composed, and in what order, is the + // frontend's business. + Route::get('services', [ShowcaseController::class, 'services'])->name('services.index'); + Route::get('services/{slug}', [ShowcaseController::class, 'service'])->name('services.show'); + Route::get('clients', [ShowcaseController::class, 'clients'])->name('clients'); + Route::get('testimonials', [ShowcaseController::class, 'testimonials'])->name('testimonials'); + Route::get('talks', [ShowcaseController::class, 'talks'])->name('talks'); + }); + +/* +|-------------------------------------------------------------------------- +| Public API — the write half +|-------------------------------------------------------------------------- +| +| The only endpoints on the public API that are not read-only, and therefore +| the only ones a stranger can use to put a row in the database. They carry +| their own throttle, because the read budget is sized for an SSR frontend +| making hundreds of requests a minute and would be meaningless here. +| +| `cache.public` is absent on purpose: a confirmation is nobody else's, and a +| shared cache holding one would hand it to the next visitor. +| +*/ + +Route::prefix('public/v1') + ->name('api.public.') + ->middleware(['throttle:public-write', 'site.available']) + ->group(function () { + Route::post('calls/{slug}/apply', [ApplicationController::class, 'apply']) + ->name('calls.apply'); + + Route::post('contact', [ApplicationController::class, 'contact']) + ->name('contact'); + }); + +/* +|-------------------------------------------------------------------------- +| Public API — the frontend reporting back +|-------------------------------------------------------------------------- +| +| Also a write endpoint, but not the same kind: the author is the frontend +| itself rather than a visitor, so it can hold a shared secret and does. It +| carries its own throttle for the same reason the submissions do — the read +| budget is sized for an SSR frontend and would mean nothing here. +| +| Outside `site.available` on purpose. Maintenance mode is about what visitors +| are shown; a 404 that happens while the site is down is still a 404 worth +| knowing about, and dropping it would lose exactly the reports produced by +| whatever change the maintenance was for. +| +*/ + +Route::prefix('public/v1') + ->name('api.public.') + ->middleware(['throttle:public-telemetry', 'ingest']) + ->group(function () { + Route::post('telemetry/not-found', [TelemetryController::class, 'notFound']) + ->name('telemetry.not-found'); + + Route::post('telemetry/views', [TelemetryController::class, 'views']) + ->name('telemetry.views'); + }); + +/* +|-------------------------------------------------------------------------- +| Admin API — authenticated +|-------------------------------------------------------------------------- +*/ + +Route::prefix('v1')->name('api.v1.')->group(function () { + + // Convenience endpoint for first-party clients: exchanges credentials for + // a token upstream so they do not need to know the identity server's URL. + Route::post('auth/token', [TokenController::class, 'store']) + ->middleware('throttle:6,1') + ->name('auth.token'); + + Route::middleware(['auth:api', 'throttle:api'])->group(function () { + Route::get('me', MeController::class)->name('me'); + + Route::apiResource('pages', PageController::class); + Route::post('pages/{page}/restore', [PageController::class, 'restore']) + ->withTrashed() + ->name('pages.restore'); + + Route::apiResource('categories', CategoryController::class); + + Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']); + + // Declared before the resource route so "catalogue" is not swallowed + // by the {role} parameter. + Route::get('roles/catalogue', [RoleController::class, 'catalogue'])->name('roles.catalogue'); + Route::apiResource('roles', RoleController::class)->only(['index', 'show']); + }); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..23b6034 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,46 @@ +everyFiveMinutes() + ->withoutOverlapping() + ->runInBackground(); + +/* + * Safety net for image processing. Uploads queue their own work, but a worker + * that is down would otherwise leave images without dimensions indefinitely — + * and the symptom, a page that reflows as it loads, is easy to miss. + */ +Schedule::command('media:process') + ->everyTenMinutes() + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('repos:sync') + ->cron(match ($cron = (string) config('git.sync_cron', 'hourly')) { + 'hourly' => '0 * * * *', + 'daily' => '0 4 * * *', + default => $cron, + }) + ->withoutOverlapping() + ->runInBackground(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..0d92dff --- /dev/null +++ b/routes/web.php @@ -0,0 +1,61 @@ +name('login'); + +Route::middleware('guest')->group(function () { + Route::get('/auth/redirect', [PireonAuthController::class, 'redirect']) + ->name('auth.redirect'); + + Route::get('/auth/callback', [PireonAuthController::class, 'callback']) + ->name('auth.callback'); +}); + +Route::post('/logout', [PireonAuthController::class, 'logout']) + ->middleware('auth') + ->name('logout'); + +/* +|-------------------------------------------------------------------------- +| Locale switching +|-------------------------------------------------------------------------- +*/ + +Route::get('/locale/{locale}', LocaleController::class)->name('locale.switch'); + +/* +|-------------------------------------------------------------------------- +| API documentation +|-------------------------------------------------------------------------- +| +| Browsable docs for both APIs, rendered from public/openapi.yaml. Readable +| without signing in on purpose: the people who need it most are writing the +| public frontend and have no reason to hold a panel account, and the +| specification describes endpoints rather than secrets. +| +| To keep it to the collective instead, wrap this route in the `auth` +| middleware and move openapi.yaml out of public/. +| +*/ + +Route::get('/docs/api', DocsController::class)->name('docs.api'); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/FakesPireon.php b/tests/FakesPireon.php new file mode 100644 index 0000000..46bbec4 --- /dev/null +++ b/tests/FakesPireon.php @@ -0,0 +1,94 @@ + $identity Overrides for the GET /api/user payload. + * @param array $token Overrides for the POST /oauth/token payload. + */ + protected function fakePireon(array $identity = [], array $token = []): void + { + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response([ + 'access_token' => 'fake-access-token', + 'refresh_token' => 'fake-refresh-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'scope' => 'account:read', + ...$token, + ]), + + "{$this->pireonServer}/api/user" => Http::response([ + 'id' => 4242, + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'is_active' => true, + 'roles' => ['user'], + 'app_roles' => ['admin'], + 'approved_at' => '2026-01-01T00:00:00+00:00', + 'created_at' => '2025-12-01T00:00:00+00:00', + ...$identity, + ]), + ]); + } + + /** + * Make the identity server reject whatever token it is handed. + */ + protected function fakePireonRejectingTokens(): void + { + Http::fake([ + "{$this->pireonServer}/api/user" => Http::response(['message' => 'Unauthenticated.'], 401), + ]); + } + + /** + * A local user holding the given roles, as if they had already signed in. + * + * @param array|string $roles + */ + protected function userWithRoles(array|string $roles, array $attributes = []): User + { + $user = User::factory()->create($attributes); + $user->syncRoles((array) $roles); + + return $user->fresh(); + } + + /** + * Authenticate API requests as $user by faking the upstream introspection + * call that the "api" guard performs. + */ + protected function apiHeadersFor(User $user): array + { + Http::fake([ + "{$this->pireonServer}/api/user" => Http::response([ + 'id' => $user->oauth_id, + 'name' => $user->name, + 'email' => $user->email, + 'is_active' => true, + 'roles' => ['user'], + 'app_roles' => [], + 'approved_at' => '2026-01-01T00:00:00+00:00', + ]), + ]); + + return [ + 'Authorization' => 'Bearer token-for-'.$user->oauth_id, + 'Accept' => 'application/json', + ]; + } +} diff --git a/tests/Feature/Api/ApiDocsTest.php b/tests/Feature/Api/ApiDocsTest.php new file mode 100644 index 0000000..04ccf22 --- /dev/null +++ b/tests/Feature/Api/ApiDocsTest.php @@ -0,0 +1,115 @@ +get('/docs/api') + ->assertOk() + ->assertSee('swagger', escape: false); + } + + #[Test] + public function it_points_at_the_installation_being_looked_at(): void + { + $html = (string) $this->get('/docs/api')->assertOk()->getContent(); + + // Not the canonical URL from the configuration: the same page is + // opened on a laptop, on staging and in production, and "try it out" + // has to reach whichever one it is. + $this->assertStringContainsString(json_encode(url('/api')), $html); + $this->assertStringContainsString(json_encode(asset('openapi.yaml')), $html); + } + + #[Test] + public function it_carries_its_own_assets(): void + { + $html = (string) $this->get('/docs/api')->assertOk()->getContent(); + + // The docs have to work on a laptop with no internet, and a script tag + // pointing at somebody else's server is a dependency this application + // does not otherwise have. + $this->assertStringNotContainsString('cdn.', $html); + $this->assertStringNotContainsString('unpkg', $html); + + foreach (['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js'] as $asset) { + $this->assertFileExists(public_path("vendor/swagger-ui/{$asset}")); + $this->assertStringContainsString("vendor/swagger-ui/{$asset}", $html); + } + } + + #[Test] + public function the_specification_parses(): void + { + $spec = Yaml::parseFile(public_path('openapi.yaml')); + + $this->assertIsArray($spec); + $this->assertArrayHasKey('paths', $spec); + $this->assertNotEmpty($spec['paths']); + } + + /** + * Every path the specification advertises exists as a route. + * + * Documentation that describes an endpoint nobody implemented is worse + * than none: it is believed. + */ + #[Test] + public function every_documented_endpoint_is_routed(): void + { + $spec = Yaml::parseFile(public_path('openapi.yaml')); + + $routes = collect(app('router')->getRoutes()->getRoutes()) + ->flatMap(fn ($route) => collect($route->methods()) + ->map(fn (string $method) => strtolower($method).' '.$route->uri())) + ->all(); + + $missing = []; + + foreach ($spec['paths'] as $path => $operations) { + // The specification is served under an `/api` base URL, and route + // parameters are named independently on either side. + $uri = 'api'.preg_replace('/\{[^}]+\}/', '{param}', $path); + + // A path item may also carry `parameters`, `summary` and friends; + // only the HTTP methods describe an endpoint. + $methods = array_intersect( + array_keys($operations), + ['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'], + ); + + foreach ($methods as $method) { + $normalised = collect($routes) + ->map(fn (string $route) => preg_replace('/\{[^}]+\}/', '{param}', $route)) + ->contains(strtolower($method).' '.$uri); + + if (! $normalised) { + $missing[] = strtoupper($method)." {$path}"; + } + } + } + + $this->assertSame([], $missing, "Documented but not routed:\n".implode("\n", $missing)); + } +} diff --git a/tests/Feature/Api/ApiTypesTest.php b/tests/Feature/Api/ApiTypesTest.php new file mode 100644 index 0000000..792eddf --- /dev/null +++ b/tests/Feature/Api/ApiTypesTest.php @@ -0,0 +1,84 @@ +artisan('api:types --check')->assertExitCode(0); + } + + #[Test] + public function it_describes_the_public_payloads(): void + { + $types = (string) file_get_contents(public_path('api-types.d.ts')); + + // The record shapes. + $this->assertStringContainsString('export interface PublicProject {', $types); + $this->assertStringContainsString('export interface Image {', $types); + + // And the map from an address to what it answers with, which is what + // makes a typed client possible without repeating any of this. + $this->assertStringContainsString("'/public/v1/projects': PublicProject[];", $types); + $this->assertStringContainsString("'/public/v1/projects/{slug}': PublicProject;", $types); + } + + /** + * `nullable` is how this specification says "may not be there", and the + * contract test holds the API to the same reading. The types have to agree + * with both or they would be describing a third thing. + */ + #[Test] + public function a_nullable_field_is_optional_and_a_required_one_is_not(): void + { + $types = (string) file_get_contents(public_path('api-types.d.ts')); + + $this->assertMatchesRegularExpression('/^ id: number;$/m', $types); + $this->assertMatchesRegularExpression('/^ tagline\?: string;$/m', $types); + } + + /** + * Administration schemas are in the same document and deliberately not + * here: they are read by callers holding a token, not by the frontend, and + * a type file twice the size is one nobody reads. + */ + #[Test] + public function it_leaves_out_what_the_frontend_never_sees(): void + { + $types = (string) file_get_contents(public_path('api-types.d.ts')); + + $this->assertStringNotContainsString('export interface User', $types); + $this->assertStringNotContainsString('export interface Role', $types); + $this->assertStringNotContainsString('PasswordGrant', $types); + } + + #[Test] + public function generating_twice_produces_the_same_file(): void + { + $before = (string) file_get_contents(public_path('api-types.d.ts')); + + $this->artisan('api:types')->assertExitCode(0); + + // Anything unstable here — a hash map iterated in whatever order, a + // timestamp in the header — would show up as a diff on every run and + // teach everybody to ignore this file's changes. + $this->assertSame($before, (string) file_get_contents(public_path('api-types.d.ts'))); + } +} diff --git a/tests/Feature/Api/ContentViewsTest.php b/tests/Feature/Api/ContentViewsTest.php new file mode 100644 index 0000000..75dfc13 --- /dev/null +++ b/tests/Feature/Api/ContentViewsTest.php @@ -0,0 +1,218 @@ + $this->token]); + $this->withHeaders(['Accept-Language' => 'es']); + } + + /** + * @param array $payload + */ + protected function report(array $payload): TestResponse + { + return $this->withHeaders(['X-Ingest-Token' => $this->token]) + ->postJson('/api/public/v1/telemetry/views', $payload); + } + + protected function page(string $slug = 'quienes-somos'): Page + { + return Page::factory()->create([ + 'slug' => ['es' => $slug, 'en' => "{$slug}-en", 'ca' => "{$slug}-ca"], + ]); + } + + #[Test] + public function it_counts_a_read(): void + { + $page = $this->page(); + + $this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es']) + ->assertAccepted() + ->assertJsonPath('data.recorded', 1) + ->assertHeader('Cache-Control', 'no-store, private'); + + $this->assertDatabaseHas('content_views', [ + 'viewable_type' => $page->getMorphClass(), + 'viewable_id' => $page->id, + 'locale' => 'es', + 'count' => 1, + ]); + } + + /** + * The property that keeps this from becoming a log. + */ + #[Test] + public function reports_for_the_same_day_add_up_in_one_row(): void + { + $this->page(); + + foreach (range(1, 4) as $ignored) { + $this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted(); + } + + $this->assertSame(1, ContentView::query()->count()); + $this->assertSame(4, (int) ContentView::query()->first()->count); + } + + #[Test] + public function a_batch_can_carry_its_own_counts(): void + { + $this->page(); + Project::factory()->create(['slug' => ['es' => 'mi-web', 'en' => 'my-web', 'ca' => 'el-meu-web']]); + + $this->report(['views' => [ + ['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es', 'count' => 12], + ['type' => 'project', 'slug' => 'mi-web', 'locale' => 'es', 'count' => 3], + ]]) + ->assertAccepted() + ->assertJsonPath('data.recorded', 2); + + $this->assertSame(15, (int) ContentView::query()->sum('count')); + } + + /** + * The same record read in two languages is two rows: which language people + * read something in is most of what the figures are for. + */ + #[Test] + public function each_language_is_counted_separately(): void + { + $page = $this->page(); + + $this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted(); + $this->report(['type' => 'page', 'slug' => 'quienes-somos-ca', 'locale' => 'ca'])->assertAccepted(); + + $this->assertSame(2, ContentView::query()->count()); + $this->assertSame(2, $page->viewsSince(30)); + } + + /** + * A slug that no longer resolves is a 404, and the endpoint next door + * exists for those. Counting it here would attach a read to nothing. + */ + #[Test] + public function a_slug_that_resolves_to_nothing_is_not_counted(): void + { + $this->report(['type' => 'page', 'slug' => 'no-existe', 'locale' => 'es']) + ->assertAccepted() + ->assertJsonPath('data.received', 1) + ->assertJsonPath('data.recorded', 0); + + $this->assertSame(0, ContentView::query()->count()); + } + + #[Test] + public function it_refuses_without_the_secret(): void + { + $this->page(); + + $this->postJson('/api/public/v1/telemetry/views', ['type' => 'page', 'slug' => 'quienes-somos']) + ->assertForbidden(); + + $this->assertSame(0, ContentView::query()->count()); + } + + #[Test] + public function it_rejects_what_it_cannot_use(): void + { + $this->report(['type' => 'invento', 'slug' => 'x']) + ->assertUnprocessable() + ->assertJsonValidationErrors('views.0.type'); + + // The frontend counts its own renders; it does not get to invent an + // audience of any size it likes. + $this->page(); + $this->report(['type' => 'page', 'slug' => 'quienes-somos', 'count' => 999999]) + ->assertUnprocessable() + ->assertJsonValidationErrors('views.0.count'); + } + + /** + * The whole privacy argument for this table, as a test: there is nowhere + * to put an address, a session or a time of day, so no future change can + * quietly start keeping one. + */ + #[Test] + public function nothing_about_a_visitor_is_stored(): void + { + $this->page(); + $this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted(); + + $columns = array_keys((array) ContentView::query()->first()->getAttributes()); + + $this->assertSame([ + 'id', 'viewable_type', 'viewable_id', 'locale', 'day', 'count', + 'created_at', 'updated_at', + ], $columns); + } + + #[Test] + public function the_listing_figure_comes_from_one_query(): void + { + $page = $this->page(); + ContentView::record($page, 'es', 5); + ContentView::record($page, 'ca', 2); + + $row = Page::query()->withRecentViews()->find($page->id); + + $this->assertSame(7, (int) $row->recent_views); + } + + #[Test] + public function figures_older_than_the_window_do_not_count_as_recent(): void + { + $page = $this->page(); + + $page->views()->create([ + 'locale' => 'es', + 'day' => now()->subDays(90)->toDateString(), + 'count' => 40, + ]); + + $this->assertSame(0, $page->viewsSince(30)); + } + + #[Test] + public function pruning_forgets_figures_nobody_compares_against(): void + { + $page = $this->page(); + + $page->views()->create(['locale' => 'es', 'day' => now()->subDays(500)->toDateString(), 'count' => 9]); + $page->views()->create(['locale' => 'es', 'day' => now()->subDays(10)->toDateString(), 'count' => 3]); + + $this->artisan('content:prune')->assertExitCode(0); + + $this->assertSame(1, ContentView::query()->count()); + $this->assertSame(3, (int) ContentView::query()->first()->count); + } +} diff --git a/tests/Feature/Api/PageApiTest.php b/tests/Feature/Api/PageApiTest.php new file mode 100644 index 0000000..e1b157d --- /dev/null +++ b/tests/Feature/Api/PageApiTest.php @@ -0,0 +1,268 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function it_rejects_a_request_without_a_token(): void + { + $this->getJson('/api/v1/pages')->assertUnauthorized(); + } + + #[Test] + public function it_rejects_a_token_the_identity_server_does_not_recognise(): void + { + $this->fakePireonRejectingTokens(); + + $this->getJson('/api/v1/pages', ['Authorization' => 'Bearer revoked-token']) + ->assertUnauthorized(); + } + + #[Test] + public function it_resolves_the_caller_and_their_local_permissions(): void + { + $user = $this->userWithRoles('editor'); + + $response = $this->getJson('/api/v1/me', $this->apiHeadersFor($user)); + + $response->assertOk() + ->assertJsonPath('data.email', $user->email) + ->assertJsonPath('data.roles.0', 'editor'); + + // Permissions come from this app, not from the identity server. + $this->assertContains('pages.create', $response->json('data.permissions')); + $this->assertNotContains('roles.create', $response->json('data.permissions')); + } + + #[Test] + public function a_locally_deactivated_user_is_denied_even_with_a_valid_token(): void + { + $user = $this->userWithRoles('editor', ['is_active' => false]); + + $this->getJson('/api/v1/pages', $this->apiHeadersFor($user)) + ->assertUnauthorized(); + } + + #[Test] + public function it_returns_content_in_the_requested_locale(): void + { + // No stored locale preference, so Accept-Language is free to decide + // when no explicit ?lang= is given. + $user = $this->userWithRoles('editor', ['locale' => null]); + + Page::factory()->published()->create([ + 'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'], + 'slug' => ['es' => 'hola', 'en' => 'hello', 'ca' => 'hola-ca'], + ]); + + $headers = $this->apiHeadersFor($user); + + $this->getJson('/api/v1/pages?lang=en', $headers) + ->assertOk() + ->assertJsonPath('data.0.title', 'Hello') + ->assertJsonPath('data.0.locale', 'en'); + + $this->getJson('/api/v1/pages?lang=ca', $headers) + ->assertOk() + ->assertJsonPath('data.0.title', 'Hola-ca'); + + // Accept-Language is honoured when no explicit ?lang= is given. + $this->getJson('/api/v1/pages', $headers + ['Accept-Language' => 'en-GB,en;q=0.9']) + ->assertOk() + ->assertJsonPath('data.0.title', 'Hello') + ->assertHeader('Content-Language', 'en'); + } + + #[Test] + public function it_can_return_every_translation_on_request(): void + { + $user = $this->userWithRoles('editor'); + + Page::factory()->published()->create([ + 'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'], + ]); + + $this->getJson('/api/v1/pages?with=translations', $this->apiHeadersFor($user)) + ->assertOk() + ->assertJsonPath('data.0.translations.title.es', 'Hola') + ->assertJsonPath('data.0.translations.title.en', 'Hello') + ->assertJsonPath('data.0.translations.title.ca', 'Hola-ca'); + } + + #[Test] + public function it_falls_back_to_the_default_locale_for_a_missing_translation(): void + { + $user = $this->userWithRoles('editor'); + + Page::factory()->published()->create([ + 'title' => ['es' => 'Solo español'], + 'slug' => ['es' => 'solo-espanol'], + ]); + + $this->getJson('/api/v1/pages?lang=ca', $this->apiHeadersFor($user)) + ->assertOk() + ->assertJsonPath('data.0.title', 'Solo español'); + } + + #[Test] + public function an_editor_can_create_a_page_with_several_translations(): void + { + $user = $this->userWithRoles('editor'); + $category = Category::factory()->create(); + + $response = $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Nueva página', 'en' => 'New page'], + 'slug' => ['es' => 'nueva-pagina', 'en' => 'new-page'], + 'category_id' => $category->id, + 'status' => 'draft', + ], $this->apiHeadersFor($user)); + + $response->assertCreated(); + + $page = Page::firstWhere('id', $response->json('data.id')); + + $this->assertSame('Nueva página', $page->getTranslation('title', 'es')); + $this->assertSame('New page', $page->getTranslation('title', 'en')); + $this->assertSame($user->id, $page->author_id); + } + + #[Test] + public function a_partial_update_does_not_wipe_other_languages(): void + { + $user = $this->userWithRoles('editor'); + + $page = Page::factory()->draft()->create([ + 'title' => ['es' => 'Original', 'en' => 'Original EN'], + 'slug' => ['es' => 'original', 'en' => 'original-en'], + 'author_id' => $user->id, + ]); + + $this->patchJson("/api/v1/pages/{$page->id}", [ + 'title' => ['ca' => 'Nou títol'], + ], $this->apiHeadersFor($user))->assertOk(); + + $page->refresh(); + + $this->assertSame('Nou títol', $page->getTranslation('title', 'ca')); + $this->assertSame('Original', $page->getTranslation('title', 'es')); + $this->assertSame('Original EN', $page->getTranslation('title', 'en')); + } + + #[Test] + public function it_rejects_a_slug_already_used_in_the_same_locale(): void + { + $user = $this->userWithRoles('editor'); + + Page::factory()->create(['slug' => ['es' => 'repetido']]); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Otra'], + 'slug' => ['es' => 'repetido'], + ], $this->apiHeadersFor($user)) + ->assertUnprocessable() + ->assertJsonValidationErrors('slug'); + } + + #[Test] + public function the_same_slug_is_allowed_in_a_different_locale(): void + { + $user = $this->userWithRoles('editor'); + + Page::factory()->create(['slug' => ['es' => 'compartido']]); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Otra'], + 'slug' => ['es' => 'distinto', 'en' => 'compartido'], + ], $this->apiHeadersFor($user))->assertCreated(); + } + + #[Test] + public function it_rejects_an_unsupported_locale_key(): void + { + $user = $this->userWithRoles('editor'); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Título', 'de' => 'Titel'], + 'slug' => ['es' => 'titulo'], + ], $this->apiHeadersFor($user)) + ->assertUnprocessable() + ->assertJsonValidationErrors('title'); + } + + #[Test] + public function a_viewer_cannot_create_a_page(): void + { + $user = $this->userWithRoles('viewer'); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'No permitido'], + 'slug' => ['es' => 'no-permitido'], + ], $this->apiHeadersFor($user))->assertForbidden(); + } + + #[Test] + public function a_viewer_only_sees_published_pages(): void + { + $user = $this->userWithRoles('viewer'); + + Page::factory()->published()->create(); + Page::factory()->draft()->create(); + + $response = $this->getJson('/api/v1/pages', $this->apiHeadersFor($user))->assertOk(); + + $this->assertCount(1, $response->json('data')); + $this->assertSame(PageStatus::Published->value, $response->json('data.0.status')); + } + + #[Test] + public function publishing_requires_its_own_permission(): void + { + // An editor who may write content but has had publish revoked. + $user = $this->userWithRoles('editor'); + $user->revokePermissionTo('pages.publish'); + $user->roles()->first()->revokePermissionTo('pages.publish'); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Intento'], + 'slug' => ['es' => 'intento'], + 'status' => 'published', + ], $this->apiHeadersFor($user)) + ->assertUnprocessable() + ->assertJsonValidationErrors('status'); + } + + #[Test] + public function a_super_admin_bypasses_every_permission_check(): void + { + $user = $this->userWithRoles('super_admin'); + + $this->assertSame(0, $user->roles()->first()->permissions()->count()); + + $this->postJson('/api/v1/pages', [ + 'title' => ['es' => 'Puedo todo'], + 'slug' => ['es' => 'puedo-todo'], + 'status' => 'published', + ], $this->apiHeadersFor($user))->assertCreated(); + } +} diff --git a/tests/Feature/Api/PublicApiContractTest.php b/tests/Feature/Api/PublicApiContractTest.php new file mode 100644 index 0000000..82ab685 --- /dev/null +++ b/tests/Feature/Api/PublicApiContractTest.php @@ -0,0 +1,275 @@ + + */ + protected array $spec; + + protected function setUp(): void + { + parent::setUp(); + + $this->withHeaders(['Accept-Language' => 'es']); + + $this->spec = (array) Yaml::parseFile(public_path('openapi.yaml')); + } + + /** + * Enough of everything for each endpoint to answer with a full record + * rather than an empty list, which would prove nothing at all. + */ + protected function seedTheSite(): void + { + $client = Client::factory()->create(); + + $project = Project::factory()->published()->create([ + 'client_id' => $client->id, + 'title' => ['es' => 'Mi proyecto'], + 'slug' => ['es' => 'mi-proyecto'], + ]); + + Page::factory()->published()->create([ + 'title' => ['es' => 'Quiénes somos'], + 'slug' => ['es' => 'quienes-somos'], + ]); + + Member::factory()->create([ + 'name' => 'Ada Lovelace', + 'slug' => 'ada-lovelace', + 'is_public' => true, + ]); + + Call::factory()->create([ + 'project_id' => $project->id, + 'title' => ['es' => 'Backend'], + 'slug' => ['es' => 'backend'], + ]); + + Service::factory()->create([ + 'title' => ['es' => 'Desarrollo'], + 'slug' => ['es' => 'desarrollo'], + ]); + + Testimonial::factory()->create(); + Talk::factory()->create(); + + // Attached to the project on purpose: `/technologies` lists only what + // is actually in use, so a loose one would leave that endpoint with + // nothing to compare against. + $project->technologies()->attach(Technology::factory()->create()); + } + + /** + * Endpoint => whether it answers with one whole record. + * + * A detail endpoint returns the full shape, so every documented field has + * to be there. A listing legitimately leaves out the heavy ones — the + * specification says as much, field by field — so it is only held to the + * rule that everything it does send is documented. + * + * @return array + */ + public static function endpoints(): array + { + return [ + 'projects' => ['path' => '/public/v1/projects', 'detail' => false], + 'project' => ['path' => '/public/v1/projects/{slug}', 'detail' => true], + 'pages' => ['path' => '/public/v1/pages', 'detail' => false], + 'page' => ['path' => '/public/v1/pages/{slug}', 'detail' => true], + 'members' => ['path' => '/public/v1/members', 'detail' => false], + 'member' => ['path' => '/public/v1/members/{slug}', 'detail' => true], + 'calls' => ['path' => '/public/v1/calls', 'detail' => false], + 'call' => ['path' => '/public/v1/calls/{slug}', 'detail' => true], + 'services' => ['path' => '/public/v1/services', 'detail' => false], + 'service' => ['path' => '/public/v1/services/{slug}', 'detail' => true], + 'clients' => ['path' => '/public/v1/clients', 'detail' => false], + 'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false], + 'talks' => ['path' => '/public/v1/talks', 'detail' => false], + 'technologies' => ['path' => '/public/v1/technologies', 'detail' => false], + ]; + } + + /** + * The URLs the seeded records actually live at. + * + * @return array + */ + protected function slugs(): array + { + return [ + '/public/v1/projects/{slug}' => '/public/v1/projects/mi-proyecto', + '/public/v1/pages/{slug}' => '/public/v1/pages/quienes-somos', + '/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace', + '/public/v1/calls/{slug}' => '/public/v1/calls/backend', + '/public/v1/services/{slug}' => '/public/v1/services/desarrollo', + ]; + } + + #[Test] + public function every_public_endpoint_answers_in_the_shape_it_documents(): void + { + $this->seedTheSite(); + + $problems = []; + + foreach (self::endpoints() as $name => $endpoint) { + $url = $this->slugs()[$endpoint['path']] ?? $endpoint['path']; + + $response = $this->getJson('/api'.$url); + + if ($response->status() !== 200) { + $problems[] = "{$name}: answered {$response->status()}"; + + continue; + } + + $payload = $response->json('data'); + + // A listing that came back empty would pass every check below + // without having compared anything. + $record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload; + + if (! is_array($record)) { + $problems[] = "{$name}: no record to compare against"; + + continue; + } + + $documented = $this->documentedKeys($endpoint['path']); + + foreach (array_diff(array_keys($record), $documented) as $undocumented) { + $problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe"; + } + + if ($endpoint['detail']) { + foreach (array_diff($this->alwaysPresent($endpoint['path']), array_keys($record)) as $missing) { + $problems[] = "{$name}: documents `{$missing}`, which it does not send"; + } + } + } + + $this->assertSame([], $problems, "The public API and its specification disagree:\n".implode("\n", $problems)); + } + + /** + * The field names the specification promises for one endpoint's records. + * + * @return array + */ + protected function documentedKeys(string $path): array + { + return array_keys($this->recordSchema($path)['properties'] ?? []); + } + + /** + * The schema of a single record, whichever way the endpoint wraps it. + * + * @return array + */ + protected function recordSchema(string $path): array + { + $schema = $this->resolve( + $this->spec['paths'][$path]['get']['responses'][200]['content']['application/json']['schema'] ?? [], + ); + + $data = $this->resolve($schema['properties']['data'] ?? []); + + // A listing documents its records under `items`; a detail endpoint + // describes them directly. + if (($data['type'] ?? null) === 'array') { + $data = $this->resolve($data['items'] ?? []); + } + + return $data; + } + + /** + * The documented fields a detail endpoint must always answer with. + * + * Anything the specification marks `nullable` is left out of this half of + * the check. That is the vocabulary this document already uses for a field + * that may not be there — a member's `role` exists only while they are + * nested under a project, and the schema says so. The other direction + * stays strict for every field either way: whatever the API sends has to + * be described, nullable or not. + * + * @return array + */ + protected function alwaysPresent(string $path): array + { + $record = $this->recordSchema($path); + + return array_keys(array_filter( + (array) ($record['properties'] ?? []), + fn (array $property) => ($property['nullable'] ?? false) !== true, + )); + } + + /** + * Follow `$ref` and flatten `allOf`, which is how the specification says + * "this, plus a couple of fields" without repeating a schema. + * + * @param array $schema + * @return array + */ + protected function resolve(array $schema): array + { + if (isset($schema['$ref'])) { + $name = basename((string) $schema['$ref']); + + return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? [])); + } + + if (isset($schema['allOf'])) { + $merged = ['type' => 'object', 'properties' => []]; + + foreach ((array) $schema['allOf'] as $part) { + $resolved = $this->resolve((array) $part); + $merged['properties'] = [ + ...$merged['properties'], + ...($resolved['properties'] ?? []), + ]; + } + + return [...$schema, ...$merged]; + } + + return $schema; + } +} diff --git a/tests/Feature/Api/PublicApiTest.php b/tests/Feature/Api/PublicApiTest.php new file mode 100644 index 0000000..f09d8be --- /dev/null +++ b/tests/Feature/Api/PublicApiTest.php @@ -0,0 +1,293 @@ +withHeaders(['Accept-Language' => 'es']); + } + + #[Test] + public function it_serves_published_projects_without_any_token(): void + { + Project::factory()->published()->create(); + Project::factory()->draft()->create(); + + $response = $this->getJson('/api/public/v1/projects')->assertOk(); + + $this->assertCount(1, $response->json('data')); + } + + #[Test] + public function it_resolves_a_project_by_slug_within_the_active_locale(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'], + 'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'], + ]); + + $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->assertJsonPath('data.title', 'Mi proyecto'); + + $this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca') + ->assertOk() + ->assertJsonPath('data.title', 'El meu projecte'); + + // A Catalan slug must not resolve while the Spanish locale is active: + // that would serve one URL under two addresses. + $this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound(); + } + + #[Test] + public function it_returns_hreflang_alternates_for_every_translated_locale(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'], + 'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'], + ]); + + $alternates = $this->getJson('/api/public/v1/projects/uno') + ->assertOk() + ->json('data.alternates'); + + // The default locale carries no prefix; the others do, each with its + // own translated path segment. + $this->assertSame('/proyectos/uno', $alternates['es']); + $this->assertSame('/en/projects/one', $alternates['en']); + $this->assertSame('/ca/projectes/u', $alternates['ca']); + } + + #[Test] + public function a_locale_without_a_translation_gets_no_alternate(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Solo español'], + 'slug' => ['es' => 'solo-espanol'], + ]); + + $alternates = $this->getJson('/api/public/v1/projects/solo-espanol') + ->assertOk() + ->json('data.alternates'); + + // Emitting an alternate here would advertise a URL that 404s. + $this->assertSame(['es'], array_keys($alternates)); + } + + #[Test] + public function listings_do_not_lazy_load_relations(): void + { + // Guards the N+1 that strict mode caught during development: the + // needs_help flag must come from a count, not from the relation. + $project = Project::factory()->published()->create(['needs_help' => false]); + Call::factory()->create(['project_id' => $project->id]); + + $this->getJson('/api/public/v1/projects') + ->assertOk() + ->assertJsonPath('data.0.needs_help', true); + } + + #[Test] + public function unpublished_content_is_hidden_unless_the_preview_secret_is_presented(): void + { + config()->set('site.preview_token', 'the-secret'); + + Project::factory()->published()->create(); + Project::factory()->draft()->create(); + + $this->assertCount(1, $this->getJson('/api/public/v1/projects')->json('data')); + + $this->assertCount(2, $this->getJson('/api/public/v1/projects', [ + 'X-Preview-Token' => 'the-secret', + ])->json('data')); + + $this->assertCount(1, $this->getJson('/api/public/v1/projects', [ + 'X-Preview-Token' => 'wrong', + ])->json('data')); + } + + #[Test] + public function an_unset_preview_secret_never_grants_access(): void + { + config()->set('site.preview_token', null); + + Project::factory()->draft()->create(); + + $this->assertCount(0, $this->getJson('/api/public/v1/projects', [ + 'X-Preview-Token' => '', + ])->json('data')); + } + + #[Test] + public function preview_responses_are_never_cached(): void + { + config()->set('site.preview_token', 'the-secret'); + + $this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret']) + ->assertHeader('Cache-Control', 'no-store, private'); + } + + #[Test] + public function public_responses_carry_caching_headers_and_answer_304(): void + { + Project::factory()->published()->create(); + + $response = $this->getJson('/api/public/v1/projects')->assertOk(); + + $etag = $response->headers->get('ETag'); + $this->assertNotNull($etag); + $this->assertStringContainsString('s-maxage', $response->headers->get('Cache-Control')); + $this->assertStringContainsString('stale-while-revalidate', $response->headers->get('Cache-Control')); + + // Language is negotiated, so a shared cache must vary on it. + $this->assertStringContainsString('Accept-Language', $response->headers->get('Vary')); + + $this->getJson('/api/public/v1/projects', ['If-None-Match' => $etag]) + ->assertStatus(304); + } + + #[Test] + public function an_open_call_states_the_time_it_asks_for(): void + { + Call::factory()->create([ + 'slug' => ['es' => 'frontend'], + 'hours_per_week' => 4, + 'duration_weeks' => 8, + 'project_id' => null, + ]); + + $this->getJson('/api/public/v1/calls/frontend') + ->assertOk() + ->assertJsonPath('data.hours_per_week', 4) + ->assertJsonPath('data.duration_weeks', 8) + ->assertJsonPath('data.total_hours', 32); + } + + #[Test] + public function a_call_never_leaks_an_unpublished_project(): void + { + $hidden = Project::factory()->draft()->create(); + Call::factory()->create(['project_id' => $hidden->id]); + Call::factory()->create(['project_id' => null]); + + $this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data')); + } + + #[Test] + public function closed_calls_are_not_listed(): void + { + Call::factory()->create(['project_id' => null]); + Call::factory()->closed()->create(['project_id' => null]); + Call::factory()->create(['project_id' => null, 'closes_at' => now()->subDay()]); + + $this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data')); + } + + #[Test] + public function members_hide_the_non_public_and_the_departed(): void + { + Member::factory()->create(['name' => 'Visible']); + Member::factory()->hidden()->create(); + Member::factory()->past()->create(); + + $data = $this->getJson('/api/public/v1/members')->assertOk()->json('data'); + + $this->assertCount(1, $data); + $this->assertSame('Visible', $data[0]['name']); + + // Alumni are still reachable when the frontend asks for them. + $this->assertCount(2, $this->getJson('/api/public/v1/members?include_past=1')->json('data')); + } + + #[Test] + public function the_radar_only_lists_placed_technologies(): void + { + Technology::factory()->count(3)->create(); + Technology::factory()->count(2)->offRadar()->create(); + + $this->assertCount(3, $this->getJson('/api/public/v1/radar')->json('data')); + } + + #[Test] + public function good_first_issues_exclude_claimed_and_hidden_ones(): void + { + $visible = Repository::factory()->create(); + $hidden = Repository::factory()->hidden()->create(); + + $visible->issues()->create([ + 'number' => 1, 'title' => 'Free to take', + 'html_url' => 'https://example.test/1', + 'is_good_first_issue' => true, 'assignee' => null, + ]); + + $visible->issues()->create([ + 'number' => 2, 'title' => 'Already claimed', + 'html_url' => 'https://example.test/2', + 'is_good_first_issue' => true, 'assignee' => 'someone', + ]); + + $hidden->issues()->create([ + 'number' => 1, 'title' => 'In a hidden repo', + 'html_url' => 'https://example.test/3', + 'is_good_first_issue' => true, 'assignee' => null, + ]); + + $data = $this->getJson('/api/public/v1/good-first-issues')->assertOk()->json('data'); + + $this->assertCount(1, $data); + $this->assertSame('Free to take', $data[0]['title']); + } + + #[Test] + public function the_slugs_endpoint_lists_every_public_url(): void + { + Project::factory()->published()->create(['slug' => ['es' => 'proyecto', 'ca' => 'projecte']]); + Member::factory()->create(['slug' => 'ada']); + + $data = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data'); + + $this->assertSame('/proyectos/proyecto', $data['projects'][0]['alternates']['es']); + $this->assertSame('/ca/projectes/projecte', $data['projects'][0]['alternates']['ca']); + $this->assertSame('/equipo/ada', $data['members'][0]['alternates']['es']); + $this->assertSame('/ca/equip/ada', $data['members'][0]['alternates']['ca']); + } + + #[Test] + public function the_site_endpoint_reports_locales_and_headline_numbers(): void + { + Project::factory()->published()->active()->create(); + Member::factory()->create(); + + $this->getJson('/api/public/v1/site') + ->assertOk() + ->assertJsonPath('data.default_locale', 'es') + ->assertJsonPath('data.stats.projects', 1) + ->assertJsonPath('data.stats.active_projects', 1) + ->assertJsonPath('data.stats.members', 1) + ->assertJsonCount(3, 'data.locales'); + } +} diff --git a/tests/Feature/Api/PublicImagesTest.php b/tests/Feature/Api/PublicImagesTest.php new file mode 100644 index 0000000..2a4fbb5 --- /dev/null +++ b/tests/Feature/Api/PublicImagesTest.php @@ -0,0 +1,178 @@ +withHeaders(['Accept-Language' => 'es']); + + // These tests are about the shape of the response, not about GD. + Queue::fake(); + } + + protected function project(array $attributes = []): Project + { + return Project::factory()->published()->create([ + 'title' => ['es' => 'Mi proyecto'], + 'slug' => ['es' => 'mi-proyecto'], + 'cover_image' => 'projects/cover.jpg', + 'cover_image_alt' => ['es' => 'Captura del panel de la asociación'], + ...$attributes, + ]); + } + + protected function markProcessed(string $path = 'projects/cover.jpg'): Media + { + $media = Media::query()->where('path', $path)->sole(); + + $media->forceFill([ + 'mime' => 'image/jpeg', + 'width' => 1600, + 'height' => 900, + 'placeholder' => 'data:image/webp;base64,UklGRhoAAABXRUJQ', + 'variants' => [ + ['format' => 'webp', 'width' => 400, 'path' => 'projects/cover-400.webp', 'size' => 9000], + ['format' => 'webp', 'width' => 800, 'path' => 'projects/cover-800.webp', 'size' => 30000], + ], + 'processed_at' => now(), + ])->save(); + + return $media; + } + + #[Test] + public function an_image_arrives_as_an_object_with_everything_needed_to_render_it(): void + { + $this->project(); + $this->markProcessed(); + + $image = $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->json('data.cover_image'); + + // Absolute, because the frontend is another application on another + // host and a path only this app can resolve is no use to it. + $this->assertStringStartsWith('http', $image['url']); + + // The pair that stops the page reflowing as images load. + $this->assertSame(1600, $image['width']); + $this->assertSame(900, $image['height']); + $this->assertSame(1.7778, $image['aspect_ratio']); + + $this->assertSame('Captura del panel de la asociación', $image['alt']); + $this->assertStringStartsWith('data:image/', $image['placeholder']); + $this->assertStringContainsString('400w', $image['srcset']['webp']); + } + + #[Test] + public function the_description_follows_the_language_being_read(): void + { + $this->project([ + 'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'], + 'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'], + 'cover_image_alt' => ['es' => 'Captura del panel', 'ca' => 'Captura del tauler'], + ]); + $this->markProcessed(); + + $this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca') + ->assertOk() + ->assertJsonPath('data.cover_image.alt', 'Captura del tauler'); + } + + #[Test] + public function an_image_with_no_description_says_so_rather_than_omitting_it(): void + { + $this->project(['cover_image_alt' => null]); + $this->markProcessed(); + + // `alt=""` is how a page says "this is decorative". A missing key + // would leave the frontend guessing. + $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->assertJsonPath('data.cover_image.alt', ''); + } + + #[Test] + public function an_image_still_renders_before_its_versions_are_ready(): void + { + $this->project(); + + $image = $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->json('data.cover_image'); + + // Degrades into a plain instead of a hole in the page. + $this->assertStringStartsWith('http', $image['url']); + $this->assertNull($image['width']); + $this->assertNull($image['srcset']); + } + + #[Test] + public function a_record_without_an_image_returns_null(): void + { + $this->project(['cover_image' => null, 'cover_image_alt' => null]); + + $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->assertJsonPath('data.cover_image', null); + } + + #[Test] + public function a_members_portrait_is_described_by_their_name(): void + { + Member::factory()->create([ + 'name' => 'Ada Lovelace', + 'slug' => 'ada', + 'avatar' => 'members/ada.jpg', + ]); + + // Nothing to type, so nothing to get wrong. + $this->getJson('/api/public/v1/members/ada') + ->assertOk() + ->assertJsonPath('data.avatar.alt', 'Ada Lovelace'); + } + + #[Test] + public function a_listing_does_not_cost_a_query_per_image(): void + { + Project::factory()->count(5)->published()->sequence(fn ($sequence) => [ + 'cover_image' => "projects/cover-{$sequence->index}.jpg", + ])->create(); + + DB::enableQueryLog(); + + $this->getJson('/api/public/v1/projects')->assertOk(); + + $queries = collect(DB::getQueryLog()) + ->filter(fn (array $q) => str_contains($q['query'], 'from "media"')); + + DB::disableQueryLog(); + + // One eager load for the whole page, not one lookup per row. + $this->assertLessThanOrEqual(1, $queries->count()); + } +} diff --git a/tests/Feature/Api/ShowcaseApiTest.php b/tests/Feature/Api/ShowcaseApiTest.php new file mode 100644 index 0000000..0888146 --- /dev/null +++ b/tests/Feature/Api/ShowcaseApiTest.php @@ -0,0 +1,269 @@ +withHeaders(['Accept-Language' => 'es']); + Queue::fake(); + } + + #[Test] + public function it_lists_published_services_in_the_order_they_were_arranged(): void + { + Service::factory()->create(['title' => ['es' => 'Segundo'], 'slug' => ['es' => 'segundo'], 'position' => 2]); + Service::factory()->create(['title' => ['es' => 'Primero'], 'slug' => ['es' => 'primero'], 'position' => 1]); + Service::factory()->draft()->create(['title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador']]); + + $titles = $this->getJson('/api/public/v1/services') + ->assertOk() + ->json('data.*.title'); + + $this->assertSame(['Primero', 'Segundo'], $titles); + } + + #[Test] + public function a_service_resolves_by_slug_within_its_language(): void + { + Service::factory()->create([ + 'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'], + 'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'], + ]); + + $this->getJson('/api/public/v1/services/desarrollo') + ->assertOk() + ->assertJsonPath('data.title', 'Desarrollo'); + + $this->getJson('/api/public/v1/services/desenvolupament?lang=ca') + ->assertOk() + ->assertJsonPath('data.title', 'Desenvolupament'); + + // A Catalan slug must not answer in Spanish, exactly as everywhere + // else in this API. + $this->getJson('/api/public/v1/services/desenvolupament')->assertNotFound(); + } + + #[Test] + public function a_service_carries_the_work_that_backs_it(): void + { + $service = Service::factory()->create([ + 'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'], + ]); + + $published = Project::factory()->published()->create([ + 'title' => ['es' => 'Web de la asociación'], 'slug' => ['es' => 'web'], + ]); + $draft = Project::factory()->draft()->create([ + 'title' => ['es' => 'A medias'], 'slug' => ['es' => 'a-medias'], + ]); + + $service->projects()->attach([$published->id, $draft->id]); + + $data = $this->getJson('/api/public/v1/services/desarrollo')->assertOk()->json('data'); + + // Without real work behind it a service is a promise — but an + // unpublished project is not something the public may see. + $this->assertCount(1, $data['projects']); + $this->assertSame('Web de la asociación', $data['projects'][0]['title']); + } + + #[Test] + public function a_draft_service_is_reachable_only_with_the_preview_secret(): void + { + config()->set('site.preview_token', 'secreto'); + + Service::factory()->draft()->create([ + 'title' => ['es' => 'Sin publicar'], 'slug' => ['es' => 'sin-publicar'], + ]); + + $this->getJson('/api/public/v1/services/sin-publicar')->assertNotFound(); + + $this->withHeaders(['X-Preview-Token' => 'secreto']) + ->getJson('/api/public/v1/services/sin-publicar') + ->assertOk(); + } + + #[Test] + public function the_wall_of_logos_leaves_out_whoever_asked_to_be_left_out(): void + { + $shy = Client::factory()->private()->create(['name' => 'Discreta']); + $shown = Client::factory()->create(['name' => 'Visible']); + + Project::factory()->published()->create(['client_id' => $shy->id]); + Project::factory()->published()->create(['client_id' => $shown->id]); + + $names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name'); + + // Appearing on somebody else's website is their call, not ours. + $this->assertSame(['Visible'], $names); + } + + #[Test] + public function a_client_with_nothing_published_is_not_a_logo_the_site_can_justify(): void + { + $withWork = Client::factory()->create(['name' => 'Con obra']); + Project::factory()->published()->create(['client_id' => $withWork->id]); + + $withoutWork = Client::factory()->create(['name' => 'Sin obra']); + Project::factory()->draft()->create(['client_id' => $withoutWork->id]); + + $names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name'); + + $this->assertSame(['Con obra'], $names); + + // Unless the frontend asks for all of them anyway. + $all = $this->getJson('/api/public/v1/clients?with_projects=0')->assertOk()->json('data.*.name'); + $this->assertCount(2, $all); + } + + #[Test] + public function a_translated_quote_says_that_it_was_translated(): void + { + Testimonial::factory()->create([ + 'quote' => ['es' => 'Nos cambiaron la forma de trabajar', 'en' => 'They changed how we work'], + 'original_locale' => 'es', + 'author_name' => 'Marta', + ]); + + $this->getJson('/api/public/v1/testimonials') + ->assertOk() + ->assertJsonPath('data.0.is_original', true) + ->assertJsonPath('data.0.original_locale', 'es'); + + // Read in English these are our words, not hers, and the frontend is + // told so it can say "translated from Spanish" rather than pretend. + $this->withHeaders(['Accept-Language' => 'en']) + ->getJson('/api/public/v1/testimonials') + ->assertOk() + ->assertJsonPath('data.0.is_original', false) + ->assertJsonPath('data.0.quote', 'They changed how we work'); + } + + #[Test] + public function testimonials_can_be_asked_for_by_what_they_vouch_for(): void + { + $client = Client::factory()->create(['name' => 'Asociación', 'slug' => 'asociacion']); + $service = Service::factory()->create([ + 'title' => ['es' => 'Formación'], 'slug' => ['es' => 'formacion'], + ]); + + Testimonial::factory()->create(['client_id' => $client->id, 'author_name' => 'De cliente']); + Testimonial::factory()->create(['service_id' => $service->id, 'author_name' => 'De servicio']); + Testimonial::factory()->create(['author_name' => 'Suelto']); + + // Attached to something rather than to "the home page", so a page asks + // for the ones that belong on it. + $this->getJson('/api/public/v1/testimonials?client=asociacion') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.author.name', 'De cliente'); + + $this->getJson('/api/public/v1/testimonials?service=formacion') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.author.name', 'De servicio'); + } + + #[Test] + public function a_private_testimonial_never_leaves_the_panel(): void + { + Testimonial::factory()->private()->create(['author_name' => 'Sin permiso']); + + $this->getJson('/api/public/v1/testimonials') + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + #[Test] + public function talks_come_back_newest_first_with_their_speaker(): void + { + $member = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada']); + + Talk::factory()->create(['title' => 'La vieja', 'held_at' => now()->subYears(2), 'member_id' => $member->id]); + Talk::factory()->create(['title' => 'La reciente', 'held_at' => now()->subMonth(), 'member_id' => $member->id]); + Talk::factory()->create(['title' => 'Oculta', 'is_public' => false]); + + $data = $this->getJson('/api/public/v1/talks')->assertOk()->json('data'); + + $this->assertSame(['La reciente', 'La vieja'], array_column($data, 'title')); + $this->assertSame('Ada', $data[0]['speaker']['name']); + } + + #[Test] + public function a_talks_title_is_not_translated(): void + { + Talk::factory()->create([ + 'title' => 'Taming reactive Node.js', + 'language' => 'en', + 'summary' => ['es' => 'Sobre streams', 'en' => 'About streams'], + ]); + + // The title is the name of a talk given once, in one language; + // translating it would invent a talk that never happened. + $this->getJson('/api/public/v1/talks') + ->assertOk() + ->assertJsonPath('data.0.title', 'Taming reactive Node.js') + ->assertJsonPath('data.0.language', 'en') + ->assertJsonPath('data.0.summary', 'Sobre streams'); + } + + #[Test] + public function a_project_now_carries_a_client_rather_than_a_typed_name(): void + { + $client = Client::factory()->create(['name' => 'Cruz Roja', 'slug' => 'cruz-roja']); + + Project::factory()->published()->create([ + 'title' => ['es' => 'Su web'], 'slug' => ['es' => 'su-web'], + 'client_id' => $client->id, + ]); + + $data = $this->getJson('/api/public/v1/projects/su-web')->assertOk()->json('data'); + + $this->assertSame('Cruz Roja', $data['client']['name']); + $this->assertSame('cruz-roja', $data['client']['slug']); + + // The old free-text pair is gone, not renamed. + $this->assertArrayNotHasKey('beneficiary', $data); + } + + #[Test] + public function services_appear_in_the_url_list_the_frontend_builds_its_sitemap_from(): void + { + Service::factory()->create([ + 'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'], + 'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'], + ]); + Service::factory()->draft()->create(['slug' => ['es' => 'borrador']]); + + $services = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data.services'); + + $this->assertCount(1, $services); + $this->assertSame('/servicios/desarrollo', $services[0]['alternates']['es']); + $this->assertSame('/ca/serveis/desenvolupament', $services[0]['alternates']['ca']); + } +} diff --git a/tests/Feature/Api/SubmissionApiTest.php b/tests/Feature/Api/SubmissionApiTest.php new file mode 100644 index 0000000..bec9b3c --- /dev/null +++ b/tests/Feature/Api/SubmissionApiTest.php @@ -0,0 +1,349 @@ +withHeaders(['Accept-Language' => 'es']); + } + + /** + * @param array $overrides + * @return array + */ + protected function payload(array $overrides = []): array + { + return array_merge([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.org', + 'message' => 'Tengo unas horas los fines de semana y me gustaría echar una mano.', + 'hours_per_week' => 4, + 'links' => ['github' => 'https://github.com/ada'], + 'consent' => true, + ], $overrides); + } + + protected function openCall(): Call + { + return Call::factory()->create([ + 'project_id' => null, + 'title' => ['es' => 'Backend', 'en' => 'Backend', 'ca' => 'Backend'], + 'slug' => ['es' => 'backend', 'en' => 'backend', 'ca' => 'rerefons'], + 'opens_at' => now()->subWeek(), + 'closes_at' => now()->addWeek(), + ]); + } + + #[Test] + public function it_accepts_an_application_to_an_open_call(): void + { + $call = $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertCreated() + ->assertJsonPath('data.status', 'new') + ->assertJsonPath('data.duplicate', false) + ->assertJsonStructure(['data' => ['id'], 'message']); + + $application = Application::sole(); + + $this->assertSame($call->id, $application->call_id); + $this->assertSame(ApplicationKind::Contribute, $application->kind); + $this->assertSame(ApplicationStatus::New, $application->status); + $this->assertSame('ada@example.org', $application->email); + $this->assertSame(4, $application->hours_per_week); + + // Snapshotted, so the record still reads correctly once the call is + // renamed or deleted. + $this->assertSame('Backend', $application->call_title); + } + + #[Test] + public function it_refuses_an_application_to_a_call_that_is_not_open(): void + { + Call::factory()->closed()->create([ + 'project_id' => null, + 'slug' => ['es' => 'cerrada'], + ]); + + // Taking somebody's time to write a message for a position that is + // already gone is worse than showing them no form at all. + $this->postJson('/api/public/v1/calls/cerrada/apply', $this->payload()) + ->assertNotFound(); + + $this->assertSame(0, Application::query()->count()); + } + + #[Test] + public function it_requires_the_fields_that_make_a_reply_possible(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', [ + 'name' => 'A', + 'email' => 'not-an-email', + 'message' => 'hola', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'email', 'message', 'consent']); + } + + #[Test] + public function it_will_not_store_personal_data_without_consent(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload(['consent' => false])) + ->assertUnprocessable() + ->assertJsonValidationErrors('consent'); + + $this->assertSame(0, Application::query()->count()); + } + + #[Test] + public function a_honeypot_submission_is_filed_as_spam_rather_than_dropped(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ + config('site.submissions.honeypot') => 'http://buy-cheap-things.example', + ]))->assertCreated() + // The bot is told nothing: an obvious rejection is a hint to try + // again differently. + ->assertJsonPath('data.status', 'new'); + + // Nothing is thrown away. The honeypot is a heuristic, and a browser + // extension can trip it for a real person. + $this->assertSame(ApplicationStatus::Spam, Application::sole()->status); + } + + #[Test] + public function a_repeat_submission_is_treated_as_the_same_one(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertCreated(); + + // A double-clicked submit button is not a second person, and an error + // would only make the visitor try harder. + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertOk() + ->assertJsonPath('data.duplicate', true); + + $this->assertSame(1, Application::query()->count()); + } + + #[Test] + public function the_same_person_may_still_write_about_something_else(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); + $this->postJson('/api/public/v1/contact', $this->payload())->assertCreated(); + + $this->assertSame(2, Application::query()->count()); + } + + #[Test] + public function the_general_form_distinguishes_offering_help_from_asking_for_it(): void + { + $this->postJson('/api/public/v1/contact', $this->payload([ + 'kind' => ApplicationKind::Request->value, + 'organisation' => 'Asociación Vecinal', + 'email' => 'hola@asociacion.example', + ]))->assertCreated(); + + $application = Application::sole(); + + $this->assertSame(ApplicationKind::Request, $application->kind); + $this->assertSame('Asociación Vecinal', $application->organisation); + $this->assertNull($application->call_id); + } + + #[Test] + public function an_organisation_asking_for_help_has_to_say_who_it_is(): void + { + $this->postJson('/api/public/v1/contact', $this->payload([ + 'kind' => ApplicationKind::Request->value, + ]))->assertUnprocessable() + ->assertJsonValidationErrors('organisation'); + } + + #[Test] + public function the_kind_cannot_be_chosen_when_answering_a_call(): void + { + $this->openCall(); + + // Applying to a call is always an offer of time; letting the payload + // say otherwise would put replies in the wrong queue. + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ + 'kind' => ApplicationKind::Request->value, + ]))->assertUnprocessable() + ->assertJsonValidationErrors('kind'); + } + + #[Test] + public function it_records_the_language_the_visitor_wrote_in(): void + { + $this->openCall(); + + // Reached through the Catalan URL of the same call — a slug only + // resolves inside its own language. + $this->postJson('/api/public/v1/calls/rerefons/apply?lang=ca', $this->payload()) + ->assertCreated(); + + // So the reply goes out in the same language they used. + $this->assertSame('ca', Application::sole()->locale); + } + + #[Test] + public function it_keeps_a_hash_of_the_address_rather_than_the_address(): void + { + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertCreated(); + + $application = Application::sole(); + + $this->assertNotNull($application->ip_hash); + $this->assertSame(64, strlen($application->ip_hash)); + $this->assertStringNotContainsString('127.0.0.1', $application->ip_hash); + } + + #[Test] + public function a_confirmation_is_never_cached(): void + { + $this->openCall(); + + $response = $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertCreated(); + + // A shared cache holding one of these would hand it to the next + // visitor. + $this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + #[Test] + public function it_tells_the_people_who_can_read_the_inbox(): void + { + Notification::fake(); + $this->seed(RolePermissionSeeder::class); + + $editor = User::factory()->create()->syncRoles('editor')->fresh(); + $viewer = User::factory()->create()->syncRoles('viewer')->fresh(); + + $this->openCall(); + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); + + Notification::assertSentTo($editor, ApplicationReceived::class); + + // A viewer has no business reading strangers' contact details, so + // they are not told about them either. + Notification::assertNotSentTo($viewer, ApplicationReceived::class); + } + + #[Test] + public function spam_does_not_wake_anybody_up(): void + { + Notification::fake(); + $this->seed(RolePermissionSeeder::class); + + $editor = User::factory()->create()->syncRoles('editor')->fresh(); + + $this->openCall(); + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ + config('site.submissions.honeypot') => 'spam', + ]))->assertCreated(); + + Notification::assertNotSentTo($editor, ApplicationReceived::class); + } + + #[Test] + public function submissions_stop_while_the_public_site_is_in_maintenance(): void + { + $this->openCall(); + + Maintenance::enable(['es' => 'Volvemos en un rato']); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertServiceUnavailable() + ->assertJsonPath('maintenance', true); + + $this->assertSame(0, Application::query()->count()); + } + + #[Test] + public function the_forms_can_be_closed_without_a_deploy(): void + { + config()->set('site.submissions.enabled', false); + + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) + ->assertServiceUnavailable() + ->assertJsonPath('submissions_enabled', false); + + // And the frontend learns it from /site, so it hides the form instead + // of rendering one that would be refused. + $this->getJson('/api/public/v1/site') + ->assertOk() + ->assertJsonPath('data.submissions_enabled', false); + } + + #[Test] + public function one_address_cannot_flood_the_inbox(): void + { + config()->set('site.submissions.rate_limit', 2); + // Otherwise the second request would be answered as a duplicate and + // never reach the limiter. + config()->set('site.submissions.deduplicate_minutes', 0); + + $this->openCall(); + + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); + $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertTooManyRequests(); + } + + #[Test] + public function the_read_endpoints_stay_read_only(): void + { + $this->openCall(); + + // The write routes are explicit; nothing else on the public API + // acquired a verb along the way. + $this->postJson('/api/public/v1/projects')->assertMethodNotAllowed(); + $this->postJson('/api/public/v1/calls')->assertMethodNotAllowed(); + } +} diff --git a/tests/Feature/Api/TelemetryApiTest.php b/tests/Feature/Api/TelemetryApiTest.php new file mode 100644 index 0000000..ce8a4ca --- /dev/null +++ b/tests/Feature/Api/TelemetryApiTest.php @@ -0,0 +1,301 @@ + $this->token]); + + $this->withHeaders(['Accept-Language' => 'es']); + } + + /** + * @param array $payload + */ + protected function report(array $payload): TestResponse + { + return $this->withHeaders(['X-Ingest-Token' => $this->token]) + ->postJson('/api/public/v1/telemetry/not-found', $payload); + } + + #[Test] + public function it_records_an_address_that_could_not_be_resolved(): void + { + $this->report([ + 'path' => '/proyectos/nombre-viejo', + 'locale' => 'es', + 'referrer' => 'https://example.net/enlace', + ]) + ->assertAccepted() + ->assertJsonPath('data.recorded', 1) + // A confirmation is nobody else's, and this one is not cacheable + // for the same reason a submission's is not. + ->assertHeader('Cache-Control', 'no-store, private'); + + $this->assertDatabaseHas('missing_urls', [ + 'path' => '/proyectos/nombre-viejo', + 'locale' => 'es', + 'hits' => 1, + 'referrer' => 'https://example.net/enlace', + 'status' => MissingUrlStatus::Pending->value, + ]); + } + + /** + * The property that makes this a counter rather than a log: a crawler + * walking a dead section must cost one row, not one row per request. + */ + #[Test] + public function reporting_the_same_address_again_counts_instead_of_repeating(): void + { + foreach (range(1, 3) as $ignored) { + $this->report(['path' => '/equipo/quien-sea', 'locale' => 'es'])->assertAccepted(); + } + + $this->assertSame(1, MissingUrl::query()->count()); + $this->assertSame(3, (int) MissingUrl::query()->first()->hits); + } + + /** + * Paths are normalised exactly as redirects are. If these two ever + * disagree, the list fills with addresses that already have a rule. + */ + #[Test] + public function it_normalises_the_path_the_way_a_redirect_does(): void + { + $this->report(['path' => '/proyectos/algo/', 'locale' => 'es'])->assertAccepted(); + $this->report(['path' => '/proyectos/algo?utm_source=twitter', 'locale' => 'es'])->assertAccepted(); + + $this->assertSame(1, MissingUrl::query()->count()); + $this->assertSame('/proyectos/algo', MissingUrl::query()->first()->path); + } + + #[Test] + public function it_accepts_a_batch(): void + { + $this->report(['hits' => [ + ['path' => '/uno', 'locale' => 'es'], + ['path' => '/dos', 'locale' => 'en'], + ['path' => '/tres'], + ]]) + ->assertAccepted() + ->assertJsonPath('data.recorded', 3); + + $this->assertSame(3, MissingUrl::query()->count()); + } + + /** + * The locale is half of the unique key, so it can never be null — the + * negotiated one stands in when the frontend does not say. + */ + #[Test] + public function a_report_without_a_locale_falls_back_to_the_negotiated_one(): void + { + $this->withHeaders(['Accept-Language' => 'ca']) + ->report(['path' => '/sense-idioma']) + ->assertAccepted(); + + $this->assertDatabaseHas('missing_urls', ['path' => '/sense-idioma', 'locale' => 'ca']); + } + + /** + * The same address in two languages is two problems: they are fixed by two + * different redirects, because the paths differ per language. + */ + #[Test] + public function the_same_path_in_two_languages_is_two_rows(): void + { + $this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted(); + $this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted(); + + $this->assertSame(2, MissingUrl::query()->count()); + } + + #[Test] + public function it_drops_scans_for_things_that_were_never_here(): void + { + $this->report(['hits' => [ + ['path' => '/wp-admin/setup-config.php'], + ['path' => '/.env'], + ['path' => '/proyectos/de-verdad'], + ]]) + ->assertAccepted() + ->assertJsonPath('data.received', 3) + // Reported honestly: the frontend can tell its filter from ours. + ->assertJsonPath('data.recorded', 1); + + $this->assertSame(1, MissingUrl::query()->count()); + $this->assertSame('/proyectos/de-verdad', MissingUrl::query()->first()->path); + } + + /** + * A later sighting without a referrer is what a bot produces. It must not + * erase the referrer that told us who is linking to a dead address. + */ + #[Test] + public function a_later_sighting_never_erases_a_known_referrer(): void + { + $this->report(['path' => '/algo', 'locale' => 'es', 'referrer' => 'https://example.net/post']) + ->assertAccepted(); + + $this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted(); + + $this->assertSame('https://example.net/post', MissingUrl::query()->first()->referrer); + } + + #[Test] + public function it_refuses_without_the_secret(): void + { + $this->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo']) + ->assertForbidden(); + + $this->withHeaders(['X-Ingest-Token' => 'wrong']) + ->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo']) + ->assertForbidden(); + + $this->assertSame(0, MissingUrl::query()->count()); + } + + /** + * The rule the preview secret already follows: an unset secret disables + * the endpoint, it never opens it. + */ + #[Test] + public function an_unset_secret_grants_nobody_access(): void + { + config(['site.telemetry.token' => null]); + + $this->withHeaders(['X-Ingest-Token' => '']) + ->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo']) + ->assertForbidden(); + + $this->assertSame(0, MissingUrl::query()->count()); + } + + #[Test] + public function it_rejects_what_it_cannot_use(): void + { + $this->report(['path' => 'https://otro-dominio.example/algo']) + ->assertUnprocessable() + ->assertJsonValidationErrors('hits.0.path'); + + $this->report(['hits' => []])->assertUnprocessable(); + + $this->report(['path' => '/algo', 'locale' => 'de']) + ->assertUnprocessable() + ->assertJsonValidationErrors('hits.0.locale'); + } + + #[Test] + public function it_caps_how_much_one_request_may_carry(): void + { + config(['site.telemetry.max_batch' => 2]); + + $this->report(['hits' => [ + ['path' => '/uno'], ['path' => '/dos'], ['path' => '/tres'], + ]])->assertUnprocessable(); + } + + /** + * Maintenance is about what visitors are shown. A 404 during it is still a + * 404 — and it is the most likely moment to produce the interesting ones. + */ + #[Test] + public function it_keeps_listening_while_the_site_is_down_for_maintenance(): void + { + app(Maintenance::class)->enable(); + + $this->report(['path' => '/durante-el-mantenimiento', 'locale' => 'es']) + ->assertAccepted(); + + $this->assertDatabaseHas('missing_urls', ['path' => '/durante-el-mantenimiento']); + } + + /** + * The loop this feature exists to close: a report becomes a redirect, and + * the report stops asking to be dealt with. + */ + #[Test] + public function writing_a_redirect_resolves_the_report_it_answers(): void + { + $this->report(['path' => '/proyectos/viejo', 'locale' => 'es'])->assertAccepted(); + + Redirect::create([ + 'from' => '/proyectos/viejo', + 'to' => '/proyectos/nuevo', + 'status' => 301, + 'locale' => 'es', + 'is_enabled' => true, + ]); + + $this->assertSame( + MissingUrlStatus::Resolved, + MissingUrl::query()->first()->status, + ); + } + + #[Test] + public function a_redirect_for_every_language_resolves_every_languages_report(): void + { + $this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted(); + $this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted(); + + Redirect::create([ + 'from' => '/algo', + 'to' => '/otra-cosa', + 'status' => 301, + 'locale' => null, + 'is_enabled' => true, + ]); + + $this->assertSame(0, MissingUrl::query()->pending()->count()); + } + + /** + * A redirect that was written and then deleted leaves the address broken + * again. Being told about it once must not mean being told never again. + */ + #[Test] + public function an_address_seen_again_after_being_dealt_with_comes_back(): void + { + $url = MissingUrl::factory()->resolved()->create([ + 'path' => '/vuelve', + 'locale' => 'es', + ]); + + $this->report(['path' => '/vuelve', 'locale' => 'es'])->assertAccepted(); + + $url->refresh(); + + $this->assertSame(MissingUrlStatus::Pending, $url->status); + $this->assertNull($url->resolved_at); + } +} diff --git a/tests/Feature/Api/TokenEndpointTest.php b/tests/Feature/Api/TokenEndpointTest.php new file mode 100644 index 0000000..8856774 --- /dev/null +++ b/tests/Feature/Api/TokenEndpointTest.php @@ -0,0 +1,121 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function it_exchanges_credentials_for_a_token_and_provisions_the_user(): void + { + $this->fakePireon(); + + $response = $this->postJson('/api/v1/auth/token', [ + 'grant_type' => 'password', + 'username' => 'ada@example.com', + 'password' => 'secret', + ]); + + $response->assertOk() + ->assertJsonPath('access_token', 'fake-access-token') + ->assertJsonPath('user.email', 'ada@example.com') + ->assertJsonPath('user.roles.0', config('pireon.default_role')); + + $this->assertDatabaseHas('users', ['oauth_id' => 4242]); + + // The password grant must reach the identity server, never a local + // password column. + Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token') + && $request['grant_type'] === 'password'); + } + + #[Test] + public function it_does_not_leak_why_a_credential_was_rejected(): void + { + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The user credentials were incorrect.', + ], 400), + ]); + + $response = $this->postJson('/api/v1/auth/token', [ + 'grant_type' => 'password', + 'username' => 'ada@example.com', + 'password' => 'wrong', + ]); + + $response->assertStatus(422); + $this->assertStringNotContainsString('credentials were incorrect', $response->content()); + } + + #[Test] + public function it_refuses_an_account_awaiting_approval(): void + { + $this->fakePireon(identity: ['approved_at' => null]); + + $this->postJson('/api/v1/auth/token', [ + 'grant_type' => 'password', + 'username' => 'ada@example.com', + 'password' => 'secret', + ]) + ->assertForbidden() + ->assertJsonPath('message', __('pireon.pending_approval')); + + $this->assertDatabaseCount('users', 0); + } + + #[Test] + public function the_password_grant_can_be_disabled(): void + { + config()->set('pireon.password_grant_enabled', false); + + $this->postJson('/api/v1/auth/token', [ + 'grant_type' => 'password', + 'username' => 'ada@example.com', + 'password' => 'secret', + ])->assertStatus(422); + } + + #[Test] + public function it_validates_the_grant_type(): void + { + $this->postJson('/api/v1/auth/token', ['grant_type' => 'implicit']) + ->assertUnprocessable() + ->assertJsonValidationErrors('grant_type'); + } + + #[Test] + public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void + { + $user = User::factory()->create(); + $user->syncRoles(['viewer']); + + $headers = $this->apiHeadersFor($user); + + $this->getJson('/api/v1/me', $headers)->assertOk(); + $this->getJson('/api/v1/me', $headers)->assertOk(); + $this->getJson('/api/v1/me', $headers)->assertOk(); + + // Introspection is cached, so three requests cost one upstream call. + Http::assertSentCount(1); + } +} diff --git a/tests/Feature/Auth/PasswordLoginTest.php b/tests/Feature/Auth/PasswordLoginTest.php new file mode 100644 index 0000000..d3d797c --- /dev/null +++ b/tests/Feature/Auth/PasswordLoginTest.php @@ -0,0 +1,257 @@ +seed(RolePermissionSeeder::class); + config()->set('pireon.password_grant_enabled', true); + } + + #[Test] + public function correct_credentials_sign_somebody_in(): void + { + $this->fakePireon(); + + Livewire::test(PireonLogin::class) + ->fillForm([ + 'email' => 'ada@example.com', + 'password' => 'correcta', + 'remember' => true, + ]) + ->call('authenticate') + ->assertHasNoFormErrors(); + + $this->assertTrue(Auth::check()); + + // The local account is provisioned on the way in, so the very first + // page already has roles to authorise against. + $user = User::query()->where('email', 'ada@example.com')->sole(); + $this->assertSame(Auth::id(), $user->id); + $this->assertSame(4242, $user->oauth_id); + } + + #[Test] + public function the_password_goes_to_the_identity_server_and_nowhere_else(): void + { + $this->fakePireon(); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 's3cr3t']) + ->call('authenticate'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token') + && $request['grant_type'] === 'password' + && $request['password'] === 's3cr3t'); + + // It is never written down: the local row has no usable password. + $user = User::query()->where('email', 'ada@example.com')->sole(); + $this->assertNotSame('s3cr3t', $user->password); + } + + #[Test] + public function wrong_credentials_say_the_same_thing_as_an_unknown_address(): void + { + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401), + ]); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'quien@example.com', 'password' => 'mala']) + ->call('authenticate') + ->assertHasFormErrors(['email']); + + // The identity server distinguishes the two; repeating that here would + // turn the form into a way of finding out who has an account. + $this->assertFalse(Auth::check()); + } + + #[Test] + public function an_identity_server_that_breaks_is_not_blamed_on_the_password(): void + { + // What a Passport server answers when it cannot sign a token at all — + // missing keys, say. It has judged nothing about these credentials. + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500), + ]); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.server_unavailable')]); + + // Saying "those credentials are wrong" here sends somebody to check a + // password that was never the problem. + $this->assertFalse(Auth::check()); + } + + #[Test] + public function an_identity_server_that_cannot_be_reached_says_so_too(): void + { + Http::fake(fn () => throw new ConnectionException('Connection refused')); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.server_unavailable')]); + } + + #[Test] + public function the_api_token_endpoint_makes_the_same_distinction(): void + { + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500), + ]); + + // 503, not 401: nothing was decided about these credentials. + $this->postJson('/api/v1/auth/token', [ + 'grant_type' => 'password', + 'username' => 'ada@example.com', + 'password' => 'correcta', + ])->assertStatus(503); + } + + #[Test] + public function a_deactivated_account_is_told_why(): void + { + $this->fakePireon(['is_active' => false]); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.inactive')]); + + // Said only once the password was right, so it reveals nothing to + // somebody guessing. + $this->assertFalse(Auth::check()); + } + + #[Test] + public function an_account_still_awaiting_approval_is_told_so(): void + { + $this->fakePireon(['approved_at' => null]); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.pending_approval')]); + + $this->assertFalse(Auth::check()); + } + + #[Test] + public function a_valid_account_with_no_role_here_is_still_refused(): void + { + // Nothing grants the default role in this test, so the user arrives + // with no way into the panel. + config()->set('pireon.default_role', null); + + $this->fakePireon(); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.no_permission')]); + + // Being a valid account upstream is not the same as being allowed in + // here: roles are this application's business. + $this->assertFalse(Auth::check()); + } + + #[Test] + public function it_says_so_plainly_when_password_sign_in_is_switched_off(): void + { + config()->set('pireon.password_grant_enabled', false); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate') + ->assertHasFormErrors(['email' => __('pireon.password_grant_disabled')]); + + // A misconfiguration is an operator's problem, not a visitor's, so it + // does not masquerade as "those credentials are incorrect". + Http::assertNothingSent(); + } + + #[Test] + public function it_stops_after_five_attempts(): void + { + Http::fake([ + "{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401), + ]); + + $component = Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'mala']); + + for ($attempt = 0; $attempt < 5; $attempt++) { + $component->call('authenticate')->assertHasFormErrors(['email']); + } + + // The sixth is refused here rather than spending the identity + // server's own allowance. + Http::fake(); + $component->call('authenticate'); + Http::assertNothingSent(); + } + + #[Test] + public function the_session_id_is_rotated_on_the_way_in(): void + { + $this->fakePireon(); + + $before = session()->getId(); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate'); + + // A session fixated before signing in must not survive now that it + // carries an identity. + $this->assertNotSame($before, session()->getId()); + } + + #[Test] + public function the_users_language_follows_them_into_the_panel(): void + { + $this->fakePireon(); + + // A pre-existing account: the default role is only granted to brand + // new identities, so this one needs one of its own to get in. + User::factory() + ->create(['email' => 'ada@example.com', 'oauth_id' => 4242, 'locale' => 'ca']) + ->assignRole('viewer'); + + Livewire::test(PireonLogin::class) + ->fillForm(['email' => 'ada@example.com', 'password' => 'correcta']) + ->call('authenticate'); + + $this->assertSame('ca', session(config('locales.session_key'))); + } +} diff --git a/tests/Feature/Auth/PireonLoginTest.php b/tests/Feature/Auth/PireonLoginTest.php new file mode 100644 index 0000000..6c124cd --- /dev/null +++ b/tests/Feature/Auth/PireonLoginTest.php @@ -0,0 +1,147 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function it_redirects_to_the_identity_server_with_a_pkce_challenge(): void + { + $response = $this->get(route('auth.redirect')); + + $response->assertRedirectContains($this->pireonServer.'/oauth/authorize'); + + $query = []; + parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); + + $this->assertSame('code', $query['response_type']); + $this->assertSame('test-client-id', $query['client_id']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertNotEmpty($query['code_challenge']); + + // The verifier itself must stay server-side; only its hash travels. + $verifier = session('pireon.code_verifier'); + $this->assertNotEmpty($verifier); + $this->assertStringNotContainsString($verifier, (string) $response->headers->get('Location')); + + $expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + $this->assertSame($expectedChallenge, $query['code_challenge']); + } + + #[Test] + public function it_signs_the_user_in_and_provisions_a_local_account(): void + { + $this->fakePireon(); + + $response = $this + ->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier']) + ->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state'])); + + $response->assertRedirect(); + + $user = User::where('oauth_id', 4242)->first(); + + $this->assertNotNull($user); + $this->assertSame('ada@example.com', $user->email); + $this->assertAuthenticatedAs($user); + + // New identities get the least-privileged role, never the app_roles + // the identity server reported. + $this->assertTrue($user->hasRole(config('pireon.default_role'))); + $this->assertFalse($user->hasRole('admin')); + $this->assertSame(['user'], $user->remote_roles); + } + + #[Test] + public function it_rejects_a_callback_whose_state_does_not_match(): void + { + $this->fakePireon(); + + $response = $this + ->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier']) + ->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'attacker-state'])); + + $response->assertRedirect(route('login')); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } + + #[Test] + public function it_refuses_an_account_still_awaiting_approval(): void + { + $this->fakePireon(identity: ['approved_at' => null]); + + $response = $this + ->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) + ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error', __('pireon.pending_approval')); + $this->assertGuest(); + } + + #[Test] + public function it_refuses_a_deactivated_account(): void + { + $this->fakePireon(identity: ['is_active' => false]); + + $response = $this + ->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) + ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); + + $response->assertSessionHas('error', __('pireon.inactive')); + $this->assertGuest(); + } + + #[Test] + public function it_adopts_a_pre_existing_local_account_with_the_same_email(): void + { + $existing = User::factory()->unlinked()->create(['email' => 'ada@example.com']); + $existing->assignRole('admin'); + + $this->fakePireon(); + + $this->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) + ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); + + $this->assertDatabaseCount('users', 1); + + $existing->refresh(); + $this->assertSame(4242, $existing->oauth_id); + + // Roles granted locally survive the first real sign-in. + $this->assertTrue($existing->hasRole('admin')); + } + + #[Test] + public function signing_out_clears_the_stored_oauth_token(): void + { + $user = $this->userWithRoles('viewer'); + + $this->actingAs($user) + ->withSession(['pireon.token' => ['access_token' => 'abc']]) + ->post(route('logout')) + ->assertRedirect(route('login')); + + $this->assertGuest(); + $this->assertNull(session('pireon.token')); + } +} diff --git a/tests/Feature/Git/GiteaSyncTest.php b/tests/Feature/Git/GiteaSyncTest.php new file mode 100644 index 0000000..7fcdd09 --- /dev/null +++ b/tests/Feature/Git/GiteaSyncTest.php @@ -0,0 +1,279 @@ +set('git.providers.gitea.url', $this->instance); + config()->set('git.providers.gitea.token', 'a-token'); + } + + /** + * @param array $repo + * @param array> $issues + * @param array> $commits + */ + protected function fakeGitea(array $repo = [], array $issues = [], array $commits = []): void + { + Http::fake([ + "{$this->instance}/api/v1/repos/*/languages" => Http::response(['PHP' => 90000, 'Blade' => 12000]), + "{$this->instance}/api/v1/repos/*/topics" => Http::response(['topics' => ['laravel', 'comunidad']]), + "{$this->instance}/api/v1/repos/*/issues*" => Http::response($issues), + "{$this->instance}/api/v1/repos/*/commits*" => Http::response($commits ?: [ + ['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]], + ['author' => ['login' => 'grace'], 'commit' => ['author' => ['email' => 'grace@x.test']]], + ['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]], + ]), + "{$this->instance}/api/v1/repos/*" => Http::response([ + 'full_name' => 'colectivo/web', + 'html_url' => "{$this->instance}/colectivo/web", + 'description' => 'La web del colectivo', + 'website' => 'https://colectivo.test', + 'default_branch' => 'main', + 'language' => 'PHP', + // Gitea's counter names differ from GitHub's. + 'stars_count' => 42, + 'forks_count' => 3, + 'watchers_count' => 7, + 'open_issues_count' => 5, + 'archived' => false, + 'fork' => false, + 'updated_at' => '2026-08-01T10:00:00Z', + 'created_at' => '2024-01-01T00:00:00Z', + ...$repo, + ]), + ]); + } + + #[Test] + public function it_reads_giteas_counter_names(): void + { + $this->fakeGitea(); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/web', + 'stars' => 0, + ]); + + app(GitManager::class)->sync($repository); + + $repository->refresh(); + + $this->assertSame(42, $repository->stars); + $this->assertSame(3, $repository->forks); + $this->assertSame(7, $repository->watchers); + $this->assertSame(5, $repository->open_issues_count); + $this->assertSame('PHP', $repository->primary_language); + $this->assertSame(['laravel', 'comunidad'], $repository->topics); + $this->assertSame('https://colectivo.test', $repository->homepage); + $this->assertNull($repository->sync_error); + } + + #[Test] + public function it_authenticates_with_giteas_token_scheme(): void + { + $this->fakeGitea(); + + app(GitManager::class)->sync( + Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web']) + ); + + // Gitea expects "token ", not "Bearer ". + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'token a-token')); + } + + #[Test] + public function it_asks_gitea_to_exclude_pull_requests(): void + { + $this->fakeGitea(); + + app(GitManager::class)->sync( + Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web']) + ); + + // Unlike GitHub, Gitea can filter PRs server-side. + Http::assertSent(fn ($request) => ! str_contains($request->url(), '/issues') + || str_contains($request->url(), 'type=issues')); + } + + #[Test] + public function it_mirrors_only_newcomer_friendly_issues(): void + { + $this->fakeGitea(issues: [ + [ + 'number' => 1, 'title' => 'Buena para empezar', 'html_url' => "{$this->instance}/x/1", + 'labels' => [['name' => 'good first issue']], 'comments' => 2, + 'user' => ['login' => 'ada'], 'assignees' => [], + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + [ + 'number' => 2, 'title' => 'Se busca ayuda', 'html_url' => "{$this->instance}/x/2", + 'labels' => [['name' => 'Se busca ayuda']], 'comments' => 0, + 'user' => ['login' => 'ada'], + // Gitea reports assignees as a list. + 'assignees' => [['login' => 'grace']], + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + [ + 'number' => 3, 'title' => 'Refactor interno', 'html_url' => "{$this->instance}/x/3", + 'labels' => [['name' => 'chore']], 'comments' => 0, + 'user' => ['login' => 'ada'], 'assignees' => [], + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + ]); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/web', + ]); + + app(GitManager::class)->sync($repository); + + $this->assertSame(2, $repository->issues()->count()); + $this->assertSame(1, $repository->goodFirstIssues()->count()); + + // The Spanish label spelling from config/git.php matches too. + $claimed = $repository->issues()->where('number', 2)->first(); + $this->assertTrue($claimed->is_help_wanted); + $this->assertSame('grace', $claimed->assignee); + $this->assertFalse($claimed->isUnclaimed()); + } + + #[Test] + public function the_contributor_count_is_derived_from_distinct_commit_authors(): void + { + $this->fakeGitea(); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/web', + ]); + + app(GitManager::class)->sync($repository); + + // Gitea has no contributors endpoint: three commits by two people. + $this->assertSame(2, $repository->fresh()->contributors_count); + } + + #[Test] + public function commit_authors_without_an_account_still_count_once(): void + { + $this->fakeGitea(commits: [ + ['commit' => ['author' => ['email' => 'sin-cuenta@x.test']]], + ['commit' => ['author' => ['email' => 'Sin-Cuenta@x.test']]], + ['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]], + ]); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/web', + ]); + + app(GitManager::class)->sync($repository); + + $this->assertSame(2, $repository->fresh()->contributors_count); + } + + #[Test] + public function a_private_repository_without_a_token_reports_the_real_cause(): void + { + Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 403)]); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/privado', + ]); + + try { + app(GitManager::class)->sync($repository); + $this->fail('Expected a GitException.'); + } catch (GitException $e) { + $this->assertSame(403, $e->status); + $this->assertStringContainsString('GITEA_TOKEN', $e->getMessage()); + } + } + + #[Test] + public function an_instance_that_is_down_is_treated_as_transient(): void + { + Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 502)]); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'colectivo/web', + ]); + + try { + app(GitManager::class)->sync($repository); + $this->fail('Expected a GitException.'); + } catch (GitException $e) { + // Self-hosted instances restart; the job should retry, not give up. + $this->assertTrue($e->isTransient); + } + } + + #[Test] + public function without_an_instance_url_gitea_is_not_offered_as_a_provider(): void + { + config()->set('git.providers.gitea.url', null); + + // A driver with nowhere to point is worse than no driver: it would + // only produce failures later. + $this->assertFalse(GitProvider::Gitea->isSupported()); + + $repository = Repository::factory()->create(['provider' => GitProvider::Gitea]); + + $this->expectException(GitException::class); + $this->expectExceptionMessage('GITEA_URL'); + + app(GitManager::class)->sync($repository); + } + + #[Test] + public function a_bare_name_is_qualified_with_the_configured_org(): void + { + config()->set('git.providers.gitea.default_owner', 'colectivo'); + + $this->fakeGitea(); + + $repository = Repository::factory()->create([ + 'provider' => GitProvider::Gitea, + 'full_name' => 'web', + ]); + + app(GitManager::class)->sync($repository); + + $this->assertSame('colectivo/web', $repository->fresh()->full_name); + Http::assertSent(fn ($r) => str_contains($r->url(), '/repos/colectivo/web')); + } + + #[Test] + public function gitea_is_the_default_provider(): void + { + $this->assertSame(GitProvider::Gitea, GitProvider::default()); + $this->assertSame('gitea', Repository::factory()->create()->provider->value); + } +} diff --git a/tests/Feature/Git/RepositorySyncTest.php b/tests/Feature/Git/RepositorySyncTest.php new file mode 100644 index 0000000..48bf7fd --- /dev/null +++ b/tests/Feature/Git/RepositorySyncTest.php @@ -0,0 +1,281 @@ +githubDefaults = ['provider' => GitProvider::GitHub]; + } + + /** @var array */ + protected array $githubDefaults = []; + + /** + * @param array $repo + * @param array> $issues + */ + protected function fakeGitHub(array $repo = [], array $issues = []): void + { + Http::fake([ + 'api.github.com/repos/*/languages' => Http::response(['PHP' => 90000, 'Blade' => 12000]), + 'api.github.com/repos/*/contributors*' => Http::response([['login' => 'a']], 200, [ + // GitHub reports the total through the Link header's last page. + 'Link' => '; rel="last"', + ]), + 'api.github.com/repos/*/issues*' => Http::response($issues), + 'api.github.com/repos/*' => Http::response([ + 'full_name' => 'colectivo/web', + 'html_url' => 'https://github.com/colectivo/web', + 'description' => 'La web del colectivo', + 'default_branch' => 'main', + 'language' => 'PHP', + 'license' => ['spdx_id' => 'AGPL-3.0'], + 'topics' => ['laravel', 'community'], + 'stargazers_count' => 128, + 'forks_count' => 9, + 'subscribers_count' => 5, + 'open_issues_count' => 12, + 'archived' => false, + 'fork' => false, + 'pushed_at' => '2026-08-01T10:00:00Z', + 'created_at' => '2024-01-01T00:00:00Z', + ...$repo, + ]), + ]); + } + + #[Test] + public function it_mirrors_the_repository_stats(): void + { + $this->fakeGitHub(); + + $repository = Repository::factory()->create([ + ...$this->githubDefaults, + 'full_name' => 'colectivo/web', + 'stars' => 0, + 'last_synced_at' => null, + ]); + + app(GitManager::class)->sync($repository); + + $repository->refresh(); + + $this->assertSame(128, $repository->stars); + $this->assertSame('PHP', $repository->primary_language); + $this->assertSame('AGPL-3.0', $repository->license); + $this->assertSame(['laravel', 'community'], $repository->topics); + $this->assertSame(['PHP' => 90000, 'Blade' => 12000], $repository->languages); + + // Contributor count is read from the Link header's last page rather + // than by paging through every contributor. + $this->assertSame(7, $repository->contributors_count); + + $this->assertNotNull($repository->last_synced_at); + $this->assertNull($repository->sync_error); + } + + #[Test] + public function it_only_mirrors_newcomer_friendly_issues(): void + { + $this->fakeGitHub(issues: [ + [ + 'number' => 1, 'title' => 'Good one', 'html_url' => 'https://x/1', + 'labels' => [['name' => 'good first issue']], 'comments' => 2, + 'user' => ['login' => 'ada'], 'assignee' => null, + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + [ + 'number' => 2, 'title' => 'Help please', 'html_url' => 'https://x/2', + 'labels' => [['name' => 'Help Wanted']], 'comments' => 0, + 'user' => ['login' => 'ada'], 'assignee' => ['login' => 'bob'], + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + // Ordinary issue: noise for a newcomer, so it is not mirrored. + [ + 'number' => 3, 'title' => 'Refactor internals', 'html_url' => 'https://x/3', + 'labels' => [['name' => 'chore']], 'comments' => 0, + 'user' => ['login' => 'ada'], 'assignee' => null, + 'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z', + ], + // A pull request is not something anyone picks up. + [ + 'number' => 4, 'title' => 'A PR', 'html_url' => 'https://x/4', + 'labels' => [['name' => 'good first issue']], 'pull_request' => ['url' => 'x'], + 'user' => ['login' => 'ada'], 'created_at' => '2026-07-01T00:00:00Z', + 'updated_at' => '2026-07-02T00:00:00Z', + ], + ]); + + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); + + app(GitManager::class)->sync($repository); + + $this->assertSame(2, $repository->issues()->count()); + $this->assertSame(1, $repository->goodFirstIssues()->count()); + + // Label matching is case-insensitive, per config/git.php. + $helpWanted = $repository->issues()->where('number', 2)->first(); + $this->assertTrue($helpWanted->is_help_wanted); + $this->assertSame('bob', $helpWanted->assignee); + $this->assertFalse($helpWanted->isUnclaimed()); + } + + #[Test] + public function issues_closed_upstream_disappear_here(): void + { + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); + + $repository->issues()->create([ + 'number' => 99, 'title' => 'Closed since', 'html_url' => 'https://x/99', + 'is_good_first_issue' => true, + ]); + + $this->fakeGitHub(issues: []); + + app(GitManager::class)->sync($repository); + + // Advertising an issue that is actually closed wastes a would-be + // contributor's time, so the mirror is replaced, not merged. + $this->assertSame(0, $repository->issues()->count()); + } + + #[Test] + public function a_missing_repository_is_recorded_and_not_retried(): void + { + Http::fake([ + 'api.github.com/*' => Http::response(['message' => 'Not Found'], 404), + ]); + + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']); + + try { + app(GitManager::class)->sync($repository); + $this->fail('Expected a GitException.'); + } catch (GitException $e) { + $this->assertSame(404, $e->status); + $this->assertFalse($e->isTransient); + } + + $repository->refresh(); + + $this->assertStringContainsString('does not exist', $repository->sync_error); + $this->assertSame(1, $repository->sync_failures); + } + + #[Test] + public function repeated_failures_flag_the_repository_in_the_panel(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']); + + foreach (range(1, 3) as $ignored) { + rescue(fn () => app(GitManager::class)->sync($repository)); + } + + $this->assertSame(3, $repository->fresh()->sync_failures); + $this->assertTrue(Repository::query()->failing()->exists()); + } + + #[Test] + public function a_rate_limit_is_reported_as_transient(): void + { + Http::fake([ + 'api.github.com/*' => Http::response(['message' => 'rate limit'], 403, [ + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (string) (time() + 120), + ]), + ]); + + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); + + try { + app(GitManager::class)->sync($repository); + $this->fail('Expected a GitException.'); + } catch (GitException $e) { + // Transient means the job should back off and retry, not give up. + $this->assertTrue($e->isTransient); + $this->assertSame(429, $e->status); + $this->assertStringContainsString('GITHUB_TOKEN', $e->getMessage()); + } + } + + #[Test] + public function a_successful_sync_clears_a_previous_error(): void + { + $repository = Repository::factory()->failing()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']); + + $this->fakeGitHub(); + + app(GitManager::class)->sync($repository); + + $repository->refresh(); + + $this->assertNull($repository->sync_error); + $this->assertSame(0, $repository->sync_failures); + } + + #[Test] + public function synced_columns_cannot_be_mass_assigned(): void + { + // A hand-typed star count is a lie waiting to happen, so the sync + // columns are deliberately absent from the fillable list. + $repository = new Repository; + $repository->fill(['full_name' => 'colectivo/web', 'stars' => 9999]); + + $this->assertSame('colectivo/web', $repository->full_name); + $this->assertNull($repository->stars); + } + + #[Test] + public function a_bare_name_is_qualified_with_the_configured_org(): void + { + config()->set('git.providers.github.default_owner', 'colectivo'); + + $this->fakeGitHub(); + + $repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'web']); + + app(GitManager::class)->sync($repository); + + // The provider's own answer wins, so the record self-corrects. + $this->assertSame('colectivo/web', $repository->fresh()->full_name); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/repos/colectivo/web')); + } + + #[Test] + public function an_unsupported_provider_fails_clearly(): void + { + $repository = Repository::factory()->create(['provider' => 'gitlab']); + + $this->expectException(GitException::class); + $this->expectExceptionMessage('No driver is registered for the [gitlab] provider'); + + app(GitManager::class)->sync($repository); + } +} diff --git a/tests/Feature/Media/ImageProcessingTest.php b/tests/Feature/Media/ImageProcessingTest.php new file mode 100644 index 0000000..49a6536 --- /dev/null +++ b/tests/Feature/Media/ImageProcessingTest.php @@ -0,0 +1,243 @@ +put($path, $contents); + + return Media::query()->create(['disk' => config('media.disk'), 'path' => $path]); + } + + #[Test] + public function it_records_the_dimensions_a_page_needs_to_reserve_the_box(): void + { + $media = $this->putImage('projects/cover.jpg', 1600, 900); + + app(ImageProcessor::class)->process($media); + + $media->refresh(); + + // Without these the page reflows as each image arrives. + $this->assertSame(1600, $media->width); + $this->assertSame(900, $media->height); + $this->assertSame(1.7778, $media->aspectRatio()); + $this->assertSame('image/jpeg', $media->mime); + $this->assertNotNull($media->processed_at); + $this->assertNull($media->error); + } + + #[Test] + public function it_derives_narrower_versions_in_a_modern_format(): void + { + $media = $this->putImage('projects/cover.jpg', 1600, 900); + + app(ImageProcessor::class)->process($media); + + $media->refresh(); + $disk = Storage::disk(config('media.disk')); + + $this->assertNotEmpty($media->variants); + + foreach ($media->variants as $variant) { + $this->assertTrue($disk->exists($variant['path']), "missing {$variant['path']}"); + $this->assertLessThan(1600, $variant['width']); + } + + // WebP is the one format every supported PHP can write; AVIF is + // produced too wherever the runtime can encode it. + $this->assertContains('webp', array_column($media->variants, 'format')); + } + + #[Test] + public function it_never_scales_an_image_up(): void + { + $media = $this->putImage('projects/small.jpg', 500, 500); + + app(ImageProcessor::class)->process($media); + + $widths = array_column($media->refresh()->variants, 'width'); + + // A 500px original must not be republished as a blurry "1600px" + // version; only the widths below it are real. + $this->assertSame([400], array_values(array_unique($widths))); + } + + #[Test] + public function it_builds_a_srcset_the_frontend_can_use_verbatim(): void + { + $media = $this->putImage('projects/cover.jpg', 1600, 900); + + app(ImageProcessor::class)->process($media); + + $srcset = $media->refresh()->srcset(); + + $this->assertArrayHasKey('webp', $srcset); + $this->assertStringContainsString('400w', $srcset['webp']); + $this->assertStringContainsString('800w', $srcset['webp']); + + // Absolute, because the frontend is a different application on a + // different host. + $this->assertStringStartsWith('http', $srcset['webp']); + } + + #[Test] + public function it_produces_a_placeholder_small_enough_to_inline(): void + { + $media = $this->putImage('projects/cover.jpg'); + + app(ImageProcessor::class)->process($media); + + $placeholder = $media->refresh()->placeholder; + + $this->assertStringStartsWith('data:image/', $placeholder); + + // It travels inside every JSON response mentioning the image, so its + // size is part of the contract, not an implementation detail. + $this->assertLessThan(2048, strlen($placeholder)); + } + + #[Test] + public function reprocessing_replaces_the_previous_versions_rather_than_piling_up(): void + { + $media = $this->putImage('projects/cover.jpg'); + + app(ImageProcessor::class)->process($media); + $first = $media->refresh()->variants; + + app(ImageProcessor::class)->process($media->refresh()); + $second = $media->refresh()->variants; + + $this->assertCount(count($first), $second); + + $disk = Storage::disk(config('media.disk')); + foreach ($second as $variant) { + $this->assertTrue($disk->exists($variant['path'])); + } + } + + #[Test] + public function a_vector_is_copied_through_untouched(): void + { + $svg = ''; + Storage::disk(config('media.disk'))->put('projects/logo.svg', $svg); + + $media = Media::query()->create(['disk' => config('media.disk'), 'path' => 'projects/logo.svg']); + + app(ImageProcessor::class)->process($media); + + $media->refresh(); + + // Resampling a vector loses the point of it, but the frontend still + // needs a size to reserve the box — so the viewBox is read. + $this->assertSame(240, $media->width); + $this->assertSame(120, $media->height); + $this->assertSame([], $media->variants); + } + + #[Test] + public function a_missing_file_is_recorded_rather_than_thrown(): void + { + $media = Media::query()->create([ + 'disk' => config('media.disk'), + 'path' => 'projects/gone.jpg', + ]); + + app(ImageProcessor::class)->process($media); + + $media->refresh(); + + // The upload keeps working as a plain image; a broken derivation must + // not take a page down with it. + $this->assertNotNull($media->error); + $this->assertNotNull($media->processed_at); + } + + #[Test] + public function saving_an_image_path_registers_it_and_queues_the_work(): void + { + Queue::fake(); + + $project = Project::factory()->create(['cover_image' => 'projects/new.jpg']); + + $media = Media::query()->where('path', 'projects/new.jpg')->sole(); + + $this->assertSame(config('media.disk'), $media->disk); + Queue::assertPushed(ProcessMedia::class); + + // An unrelated save must not queue the same work again. + Queue::fake(); + $project->update(['is_featured' => true]); + Queue::assertNotPushed(ProcessMedia::class); + } + + #[Test] + public function two_records_pointing_at_one_upload_share_a_single_row(): void + { + Queue::fake(); + + Project::factory()->create(['cover_image' => 'projects/shared.jpg']); + Project::factory()->create(['cover_image' => 'projects/shared.jpg']); + + // The file is the identity, so the second one costs no extra + // processing and no second set of derived versions. + $this->assertSame(1, Media::query()->where('path', 'projects/shared.jpg')->count()); + Queue::assertPushed(ProcessMedia::class, 1); + } + + #[Test] + public function the_command_picks_up_whatever_the_queue_did_not(): void + { + Queue::fake(); + + $this->putImage('projects/pending.jpg'); + + $this->assertSame(1, Media::query()->pending()->count()); + + $this->artisan('media:process')->assertSuccessful(); + + // The scheduler runs this, so a worker that is down costs a delay + // rather than an image that stays half-done forever. + $this->assertSame(0, Media::query()->pending()->count()); + } +} diff --git a/tests/Feature/Media/ProjectGalleryTest.php b/tests/Feature/Media/ProjectGalleryTest.php new file mode 100644 index 0000000..9a61138 --- /dev/null +++ b/tests/Feature/Media/ProjectGalleryTest.php @@ -0,0 +1,153 @@ +withHeaders(['Accept-Language' => 'es']); + Queue::fake(); + } + + protected function projectWithGallery(): Project + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Mi proyecto'], + 'slug' => ['es' => 'mi-proyecto'], + ]); + + $project->images()->createMany([ + [ + 'path' => 'projects/gallery/after.jpg', + 'alt' => ['es' => 'El panel nuevo'], + 'caption' => ['es' => 'Después'], + 'position' => 2, + ], + [ + 'path' => 'projects/gallery/before.jpg', + 'alt' => ['es' => 'La hoja de cálculo que usaban'], + 'caption' => ['es' => 'Antes'], + 'position' => 1, + ], + ]); + + return $project; + } + + #[Test] + public function the_gallery_comes_back_in_the_order_it_was_arranged(): void + { + $this->projectWithGallery(); + + $gallery = $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->json('data.gallery'); + + // An unordered gallery is a different gallery every time it is read, + // and "before" after "after" tells the wrong story. + $this->assertSame(['Antes', 'Después'], array_column($gallery, 'caption')); + } + + #[Test] + public function each_image_carries_everything_needed_to_render_it(): void + { + $this->projectWithGallery(); + + Media::query()->where('path', 'projects/gallery/before.jpg')->sole()->forceFill([ + 'width' => 1200, + 'height' => 800, + 'variants' => [ + ['format' => 'webp', 'width' => 400, 'path' => 'projects/gallery/before-400.webp', 'size' => 8000], + ], + 'processed_at' => now(), + ])->save(); + + $first = $this->getJson('/api/public/v1/projects/mi-proyecto') + ->assertOk() + ->json('data.gallery.0'); + + $this->assertStringStartsWith('http', $first['url']); + $this->assertSame(1200, $first['width']); + $this->assertSame('La hoja de cálculo que usaban', $first['alt']); + $this->assertStringContainsString('400w', $first['srcset']['webp']); + + // A caption is not a description: one is shown to everyone, the other + // is read by whoever cannot see the image. + $this->assertSame('Antes', $first['caption']); + } + + #[Test] + public function a_listing_does_not_carry_every_screenshot_of_every_project(): void + { + $this->projectWithGallery(); + + $project = $this->getJson('/api/public/v1/projects') + ->assertOk() + ->json('data.0'); + + $this->assertArrayNotHasKey('gallery', $project); + } + + #[Test] + public function adding_a_gallery_image_registers_it_like_any_other_upload(): void + { + $project = Project::factory()->create(); + + $project->images()->create(['path' => 'projects/gallery/new.jpg']); + + // Nothing special about a gallery image: same media row, same derived + // versions, same queue. + $this->assertSame(1, Media::query()->where('path', 'projects/gallery/new.jpg')->count()); + } + + #[Test] + public function erasing_a_project_for_good_takes_its_gallery_with_it(): void + { + $project = $this->projectWithGallery(); + + $project->delete(); + + // A soft delete keeps it — that is the point of a soft delete. + $this->assertSame(2, ImageAttachment::query()->count()); + + $project->forceDelete(); + + // A morph relation has no foreign key to cascade for it, so this is + // handled explicitly and worth pinning down. + $this->assertSame(0, ImageAttachment::query()->count()); + } + + #[Test] + public function the_gallery_editor_renders(): void + { + $this->seed(RolePermissionSeeder::class); + $project = $this->projectWithGallery(); + + $this->actingAs($this->userWithRoles('admin')); + + $this->get(ProjectResource::getUrl('edit', ['record' => $project])) + ->assertSuccessful(); + } +} diff --git a/tests/Feature/Panel/ApplicationsInboxTest.php b/tests/Feature/Panel/ApplicationsInboxTest.php new file mode 100644 index 0000000..c6307df --- /dev/null +++ b/tests/Feature/Panel/ApplicationsInboxTest.php @@ -0,0 +1,159 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function the_inbox_renders(): void + { + $call = Call::factory()->create(['project_id' => null]); + + Application::factory()->count(2)->create(); + Application::factory()->forCall($call->id)->create(); + Application::factory()->request()->create(); + Application::factory()->spam()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + $this->get(ApplicationResource::getUrl('index'))->assertSuccessful(); + } + + #[Test] + public function a_submission_can_be_opened(): void + { + $application = Application::factory()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + $this->get(ApplicationResource::getUrl('view', ['record' => $application])) + ->assertSuccessful() + ->assertSee($application->name); + } + + #[Test] + public function the_replies_to_a_call_show_on_the_call_itself(): void + { + $call = Call::factory()->create(['project_id' => null]); + + Application::factory()->forCall($call->id)->create(); + + $this->actingAs($this->userWithRoles('admin')); + + // "Is anybody interested?" is a question you have while looking at the + // opening, not while working through the whole inbox. + $this->get(CallResource::getUrl('edit', ['record' => $call])) + ->assertSuccessful(); + } + + #[Test] + public function an_editor_runs_the_calls_so_an_editor_answers_them(): void + { + $editor = $this->userWithRoles('editor'); + + $this->assertTrue($editor->can('applications.view_any')); + $this->assertTrue($editor->can('applications.update')); + } + + #[Test] + public function a_viewer_never_sees_a_strangers_contact_details(): void + { + $viewer = $this->userWithRoles('viewer'); + + $this->actingAs($viewer); + + // Reading the site's content and reading the people who wrote in are + // deliberately different rights. + $this->assertFalse($viewer->can('applications.view_any')); + $this->assertFalse(ApplicationResource::canViewAny()); + + $this->get(ApplicationResource::getUrl('index'))->assertForbidden(); + } + + #[Test] + public function nobody_creates_a_submission_by_hand(): void + { + $admin = $this->userWithRoles('admin'); + + $this->actingAs($admin); + + // They arrive from the public site. A hand-written one would be a + // record of a conversation that never happened. + $this->assertFalse(ApplicationResource::canCreate()); + $this->assertFalse($admin->can('create', Application::class)); + } + + #[Test] + public function answering_records_who_answered(): void + { + $admin = $this->userWithRoles('admin'); + $application = Application::factory()->create(); + + $application->markAs(ApplicationStatus::Contacted, $admin); + + $application->refresh(); + + // An inbox worked by several people otherwise turns into two replies + // or none. + $this->assertSame(ApplicationStatus::Contacted, $application->status); + $this->assertSame($admin->id, $application->handled_by); + $this->assertNotNull($application->handled_at); + } + + #[Test] + public function the_badge_counts_the_people_still_waiting(): void + { + Application::factory()->count(2)->create(); + Application::factory()->handled()->create(); + Application::factory()->spam()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + $this->assertSame('2', ApplicationResource::getNavigationBadge()); + } + + #[Test] + public function a_submission_survives_the_call_it_answered(): void + { + $call = Call::factory()->create([ + 'project_id' => null, + 'title' => ['es' => 'Backend'], + ]); + + $application = Application::factory()->forCall($call->id, 'Backend')->create(); + + $call->delete(); + + $application->refresh(); + + // The opening was tidied up; the person who wrote in was not. + $this->assertNull($application->call_id); + $this->assertSame('Backend', $application->subject()); + } +} diff --git a/tests/Feature/Panel/BulkPublishingTest.php b/tests/Feature/Panel/BulkPublishingTest.php new file mode 100644 index 0000000..6583943 --- /dev/null +++ b/tests/Feature/Panel/BulkPublishingTest.php @@ -0,0 +1,154 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function publishing_a_selection_of_pages_skips_what_is_already_live(): void + { + $draft = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]); + $alreadyLive = Page::factory()->published()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(ListPages::class) + ->callTableBulkAction('publish', [$draft, $alreadyLive]); + + $this->assertSame(PageStatus::Published, $draft->fresh()->status); + $this->assertNotNull($draft->fresh()->published_at); + } + + /** + * Publishing is what closes a pending review — the model's own hook does + * that on any `update()`, and the bulk action has to go through it one + * record at a time rather than a single UPDATE query, or this silently + * stops working. + */ + #[Test] + public function bulk_publishing_closes_a_pending_review(): void + { + $page = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]); + $page->requestReview(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(ListPages::class) + ->callTableBulkAction('publish', [$page]); + + $this->assertFalse($page->fresh()->isAwaitingReview()); + } + + #[Test] + public function somebody_who_cannot_publish_does_not_see_the_bulk_action(): void + { + Page::factory()->create(['status' => PageStatus::Draft]); + + $this->actingAs($this->userWithRoles('writer')); + + Livewire::test(ListPages::class) + ->assertTableBulkActionHidden('publish'); + } + + #[Test] + public function requesting_review_in_bulk_skips_what_is_already_awaiting_or_published(): void + { + $eligible = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]); + + $alreadyAwaiting = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]); + $alreadyAwaiting->requestReview(); + + $alreadyPublished = Page::factory()->published()->create(); + + $this->actingAs($this->userWithRoles('writer')); + + Livewire::test(ListPages::class) + ->callTableBulkAction('request_review_bulk', [$eligible, $alreadyAwaiting, $alreadyPublished]); + + $this->assertTrue($eligible->fresh()->isAwaitingReview()); + + // Untouched: neither had anything to gain from being asked again. + $this->assertSame( + $alreadyAwaiting->fresh()->review_requested_at->timestamp, + $alreadyAwaiting->review_requested_at->timestamp, + ); + $this->assertFalse($alreadyPublished->fresh()->isAwaitingReview()); + } + + /** + * The same rule as the single button: somebody who can already publish + * has no use for asking permission first. + */ + #[Test] + public function somebody_who_can_already_publish_does_not_see_the_bulk_request_action(): void + { + Page::factory()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(ListPages::class) + ->assertTableBulkActionHidden('request_review_bulk'); + } + + #[Test] + public function it_works_for_projects(): void + { + $project = Project::factory()->create(['published_at' => null]); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(ListProjects::class) + ->callTableBulkAction('publish', [$project]); + + $this->assertNotNull($project->fresh()->published_at); + } + + #[Test] + public function it_works_for_services(): void + { + $service = Service::factory()->create(['is_published' => false, 'published_at' => null]); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(ListServices::class) + ->callTableBulkAction('publish', [$service]); + + $service->refresh(); + + $this->assertTrue($service->is_published); + $this->assertNotNull($service->published_at); + } +} diff --git a/tests/Feature/Panel/CollectiveResourcesTest.php b/tests/Feature/Panel/CollectiveResourcesTest.php new file mode 100644 index 0000000..5f2ead9 --- /dev/null +++ b/tests/Feature/Panel/CollectiveResourcesTest.php @@ -0,0 +1,155 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function every_collective_resource_renders(): void + { + Project::factory()->count(2)->create(); + Member::factory()->count(2)->create(); + Technology::factory()->count(2)->create(); + Call::factory()->create(['project_id' => null]); + Repository::factory()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + foreach ([ + ProjectResource::class, + MemberResource::class, + TechnologyResource::class, + CallResource::class, + RepositoryResource::class, + ] as $resource) { + $this->get($resource::getUrl('index')) + ->assertSuccessful(); + } + } + + #[Test] + public function an_editor_can_manage_content_but_not_sync_repositories(): void + { + $editor = $this->userWithRoles('editor'); + + $this->actingAs($editor); + + $this->assertTrue($editor->can('projects.create')); + $this->assertTrue($editor->can('calls.create')); + $this->assertTrue($editor->can('members.update')); + + // Syncing spends a shared rate limit, so it is gated separately from + // ordinary editing. + $this->assertTrue($editor->can('repositories.view_any')); + $this->assertFalse($editor->can('repositories.sync')); + $this->assertFalse($editor->can('repositories.create')); + } + + #[Test] + public function a_viewer_cannot_reach_the_collective_editors(): void + { + $viewer = $this->userWithRoles('viewer'); + + $this->actingAs($viewer); + + $this->assertTrue(ProjectResource::canViewAny()); + $this->assertFalse(ProjectResource::canCreate()); + $this->assertFalse(MemberResource::canCreate()); + $this->assertFalse(TechnologyResource::canCreate()); + } + + #[Test] + public function publishing_a_project_is_a_separate_permission_from_editing_it(): void + { + $editor = $this->userWithRoles('editor'); + $editor->roles()->first()->revokePermissionTo('projects.publish'); + $editor->forgetCachedPermissions(); + + $project = Project::factory()->draft()->create(); + + $this->assertTrue($editor->can('update', $project)); + $this->assertFalse($editor->can('publish', $project)); + } + + #[Test] + public function a_member_may_always_edit_their_own_profile(): void + { + // Someone with no member-editing permission at all. + $user = $this->userWithRoles('viewer'); + $own = Member::factory()->create(['user_id' => $user->id]); + $other = Member::factory()->create(); + + $this->assertTrue($user->can('update', $own)); + $this->assertFalse($user->can('update', $other)); + } + + #[Test] + public function a_technology_still_in_use_cannot_be_deleted(): void + { + $admin = $this->userWithRoles('admin'); + + $used = Technology::factory()->create(); + $unused = Technology::factory()->create(); + + Project::factory()->create()->technologies()->attach($used); + + // Deleting it would silently strip the tag across the whole site. + $this->assertFalse($admin->can('delete', $used)); + $this->assertTrue($admin->can('delete', $unused)); + } + + #[Test] + public function a_member_is_not_a_panel_user(): void + { + $member = Member::factory()->create(); + + // The two are deliberately separate: most members never need admin + // access, and revoking admin must not remove someone from the website. + $this->assertNull($member->user_id); + $this->assertDatabaseCount('users', 0); + $this->assertTrue($member->is_public); + } + + #[Test] + public function removing_a_panel_user_leaves_the_member_on_the_site(): void + { + $user = User::factory()->create(); + $member = Member::factory()->create(['user_id' => $user->id]); + + $user->delete(); + + $member->refresh(); + + $this->assertTrue($member->exists); + $this->assertNull($member->user_id); + $this->assertTrue($member->is_public); + } +} diff --git a/tests/Feature/Panel/ConcurrentEditingTest.php b/tests/Feature/Panel/ConcurrentEditingTest.php new file mode 100644 index 0000000..b8ddeff --- /dev/null +++ b/tests/Feature/Panel/ConcurrentEditingTest.php @@ -0,0 +1,221 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function a_save_is_refused_when_the_record_changed_underneath_it(): void + { + $project = Project::factory()->create(['title' => ['es' => 'Original']]); + + $this->actingAs($this->userWithRoles('admin')); + + $editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]); + + // Somebody else — a second tab, a teammate, does not matter which — + // saves a change after this screen was opened. The truncated-second + // precision of `updated_at` means a real difference needs distance in + // time, not just a second call; a day is more than enough and mirrors + // the wall-clock gap an actual second editor would produce. + $this->travel(1)->day(); + $project->fresh()->update(['title' => ['es' => 'Cambiado por otra persona']]); + + $editor + ->fillForm(['title' => ['es' => 'Lo que yo escribía']]) + ->call('save') + ->assertNotified(__('panel.concurrency.title')); + + // Not "no form errors" — the save must not have reached the database + // at all, and the other person's write must be exactly what survives. + $this->assertSame('Cambiado por otra persona', $project->fresh()->getTranslation('title', 'es', false)); + } + + #[Test] + public function the_conflict_message_names_who_changed_it_when_the_history_knows(): void + { + $project = Project::factory()->create(['title' => ['es' => 'Original']]); + $other = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']); + + $this->actingAs($this->userWithRoles('admin')); + $editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]); + + $this->travel(1)->day(); + $this->actingAs($other); + $project->fresh()->update(['title' => ['es' => 'Cambiado']]); + + $editor + ->fillForm(['title' => ['es' => 'Mi versión']]) + ->call('save') + ->assertNotified(__('panel.concurrency.title')); + } + + #[Test] + public function saving_without_interference_works_exactly_as_before(): void + { + $project = Project::factory()->create(['title' => ['es' => 'Original']]); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->fillForm(['title' => ['es' => 'Actualizado']]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertSame('Actualizado', $project->fresh()->getTranslation('title', 'es', false)); + } + + /** + * Saving twice from the same open screen — write, then write again — is + * not a conflict with yourself. Each save has to become the new baseline, + * or nobody could ever save more than once without reloading. + */ + #[Test] + public function a_second_save_from_the_same_screen_is_not_a_conflict_with_itself(): void + { + $project = Project::factory()->create(['title' => ['es' => 'Original']]); + + $this->actingAs($this->userWithRoles('admin')); + + $editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->fillForm(['title' => ['es' => 'Primero']]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->travel(1)->day(); + + $editor + ->fillForm(['title' => ['es' => 'Segundo']]) + ->call('save') + ->assertHasNoFormErrors() + ->assertNotNotified(__('panel.concurrency.title')); + + $this->assertSame('Segundo', $project->fresh()->getTranslation('title', 'es', false)); + } + + /** + * The guard is generic, not written against any one resource. Settings is + * the one model with a string primary key rather than an + * auto-incrementing id, which is exactly the kind of assumption a naive + * `WHERE id = ?` would get wrong. + */ + #[Test] + public function it_also_guards_a_model_with_a_string_primary_key(): void + { + Setting::set('site.maintenance', ['enabled' => false]); + + $this->actingAs($this->userWithRoles('super_admin')); + + $editor = Livewire::test(EditSettings::class, ['record' => 'site.maintenance']); + + $this->travel(1)->day(); + Setting::set('site.maintenance', ['enabled' => true]); + + $editor + ->fillForm(['value' => json_encode(['enabled' => false, 'changed_by_me' => true])]) + ->call('save') + ->assertNotified(__('panel.concurrency.title')); + + $this->assertSame(['enabled' => true], Setting::get('site.maintenance')); + } + + #[Test] + public function opening_a_record_someone_else_has_open_warns_the_second_person(): void + { + $project = Project::factory()->create(); + $first = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']); + + $this->actingAs($first); + Livewire::test(EditProject::class, ['record' => $project->getKey()]); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->assertNotified(__('panel.presence.title')); + } + + /** + * The first person to open a record has no reason to expect company, and + * nothing this screen could tell them would help — so they are the one + * case that stays silent. + */ + #[Test] + public function the_first_person_to_open_a_record_is_not_warned(): void + { + $project = Project::factory()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->assertNotNotified(__('panel.presence.title')); + } + + #[Test] + public function presence_fades_after_the_window_passes(): void + { + $project = Project::factory()->create(); + $first = $this->userWithRoles('admin'); + + $this->actingAs($first); + Livewire::test(EditProject::class, ['record' => $project->getKey()]); + + // Long past the window: a tab left open and forgotten must stop + // haunting the record, not just for the display but for anyone else + // who opens it afterwards. + $this->travel(20)->minutes(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->assertNotNotified(__('panel.presence.title')); + } + + #[Test] + public function presence_never_counts_the_viewer_themselves(): void + { + $project = Project::factory()->create(); + $user = $this->userWithRoles('admin'); + + $this->assertTrue(Presence::touch($project, $user)->isEmpty()); + + // Opening it again a moment later is still just one person here. + $this->assertTrue(Presence::touch($project, $user)->isEmpty()); + } +} diff --git a/tests/Feature/Panel/ContentFormsTest.php b/tests/Feature/Panel/ContentFormsTest.php new file mode 100644 index 0000000..f259631 --- /dev/null +++ b/tests/Feature/Panel/ContentFormsTest.php @@ -0,0 +1,66 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + + $this->actingAs($this->userWithRoles('admin')); + } + + #[Test] + public function every_content_form_renders_empty(): void + { + foreach ([PageResource::class, ProjectResource::class, MemberResource::class] as $resource) { + $this->get($resource::getUrl('create'))->assertSuccessful(); + } + } + + #[Test] + public function every_content_form_renders_with_an_image_and_its_description(): void + { + $page = Page::factory()->create([ + 'featured_image' => 'pages/hero.jpg', + 'featured_image_alt' => ['es' => 'Una captura'], + ]); + + $project = Project::factory()->create([ + 'cover_image' => 'projects/cover.jpg', + 'cover_image_alt' => ['es' => 'La portada'], + ]); + + $member = Member::factory()->create(['avatar' => 'members/ada.jpg']); + + // The alt-text tabs only appear once there is an image to describe, + // so a form carrying one exercises a path the empty one does not. + $this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful(); + $this->get(ProjectResource::getUrl('edit', ['record' => $project]))->assertSuccessful(); + $this->get(MemberResource::getUrl('edit', ['record' => $member]))->assertSuccessful(); + } +} diff --git a/tests/Feature/Panel/DocsNavigationTest.php b/tests/Feature/Panel/DocsNavigationTest.php new file mode 100644 index 0000000..0775d2a --- /dev/null +++ b/tests/Feature/Panel/DocsNavigationTest.php @@ -0,0 +1,73 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function the_sidebar_links_to_the_documentation(): void + { + $this->actingAs($this->userWithRoles('super_admin')); + + $html = (string) $this->get('/admin')->assertSuccessful()->getContent(); + + $this->assertStringContainsString(route('docs.api'), $html); + $this->assertMatchesRegularExpression( + '/href="[^"]*docs\/api"[^>]*target="_blank"/', + $html, + 'The documentation should open in a new tab.', + ); + } + + #[Test] + public function anybody_who_can_reach_the_panel_can_reach_the_docs(): void + { + // Reading the API reference is not a privilege: the page itself is + // public, so hiding the link would only make it harder to find. + $this->actingAs($this->userWithRoles('viewer')); + + $this->get('/admin') + ->assertSuccessful() + ->assertSee(route('docs.api'), escape: false); + } + + /** + * The panel is configured once per process, so a label resolved there + * would freeze in whatever language happened to be active at boot. This is + * the test that catches that, and it has to run in its own request to + * mean anything. + */ + #[Test] + public function the_label_follows_the_language_being_browsed(): void + { + $this->actingAs($this->userWithRoles('super_admin')); + + $this->get('/admin?lang=en') + ->assertSuccessful() + ->assertSee(__('docs.title', [], 'en')); + } +} diff --git a/tests/Feature/Panel/DuplicateTitlesTest.php b/tests/Feature/Panel/DuplicateTitlesTest.php new file mode 100644 index 0000000..fad4796 --- /dev/null +++ b/tests/Feature/Panel/DuplicateTitlesTest.php @@ -0,0 +1,164 @@ +seed(RolePermissionSeeder::class); + $this->actingAs($this->userWithRoles('admin')); + } + + #[Test] + public function a_near_identical_title_is_found(): void + { + Page::factory()->create(['title' => ['es' => 'Gestor de socios']]); + + $matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios v2', 'es', null); + + $this->assertCount(1, $matches); + $this->assertSame('Gestor de socios', $matches->first()->getTranslation('title', 'es', false)); + } + + #[Test] + public function an_unrelated_title_is_not_a_match(): void + { + Page::factory()->create(['title' => ['es' => 'Gestor de socios']]); + + $matches = DuplicateTitles::similarTo(Page::class, 'Convocatoria de primavera', 'es', null); + + $this->assertTrue($matches->isEmpty()); + } + + /** + * The record being edited must never flag itself: typing in an existing + * page's own title field would otherwise "find" a perfect match on every + * keystroke. + */ + #[Test] + public function a_record_is_never_a_duplicate_of_itself(): void + { + $page = Page::factory()->create(['title' => ['es' => 'Gestor de socios']]); + + $matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', $page->id); + + $this->assertTrue($matches->isEmpty()); + } + + #[Test] + public function an_empty_title_matches_nothing(): void + { + Page::factory()->create(['title' => ['es' => 'Gestor de socios']]); + + $this->assertTrue(DuplicateTitles::similarTo(Page::class, '', 'es', null)->isEmpty()); + $this->assertTrue(DuplicateTitles::similarTo(Page::class, ' ', 'es', null)->isEmpty()); + } + + #[Test] + public function only_the_closest_three_are_returned(): void + { + foreach (range(1, 5) as $n) { + Page::factory()->create(['title' => ['es' => "Gestor de socios {$n}"]]); + } + + $matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', null); + + $this->assertCount(3, $matches); + } + + #[Test] + public function typing_a_near_duplicate_title_warns_while_creating(): void + { + Page::factory()->create(['title' => ['es' => 'Quiénes somos']]); + + Livewire::test(CreatePage::class) + ->fillForm(['title' => ['es' => 'Quienes somos']]) + ->assertNotified(__('panel.duplicates.title')); + } + + /** + * Editing an existing page must not warn about itself the moment the + * form is touched. + */ + #[Test] + public function editing_a_page_without_changing_its_title_does_not_warn(): void + { + $page = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]); + + Livewire::test(EditPage::class, ['record' => $page->getKey()]) + ->fillForm(['excerpt' => ['es' => 'Un resumen nuevo']]) + ->assertNotNotified(__('panel.duplicates.title')); + } + + #[Test] + public function the_warning_links_to_the_existing_record(): void + { + $existing = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]); + + $component = Livewire::test(CreatePage::class) + ->fillForm(['title' => ['es' => 'Quienes somos']]); + + // Read ahead of `assertNotified()`, which pulls this same key from + // the session and would leave nothing behind for a second read. + $notified = collect(session('filament.claimed_notifications'))->first(); + + $component->assertNotified(__('panel.duplicates.title')); + + $this->assertSame( + PageResource::getUrl('edit', ['record' => $existing]), + $notified['actions'][0]['url'] ?? null, + ); + } + + #[Test] + public function it_works_for_projects(): void + { + Project::factory()->create(['title' => ['es' => 'Web del colectivo']]); + + Livewire::test(CreateProject::class) + ->fillForm(['title' => ['es' => 'Web del colectivo v2']]) + ->assertNotified(__('panel.duplicates.title')); + } + + #[Test] + public function it_works_for_services(): void + { + Service::factory()->create(['title' => ['es' => 'Auditoría técnica']]); + + Livewire::test(CreateService::class) + ->fillForm(['title' => ['es' => 'Auditoria tecnica']]) + ->assertNotified(__('panel.duplicates.title')); + } +} diff --git a/tests/Feature/Panel/EnvironmentBannerTest.php b/tests/Feature/Panel/EnvironmentBannerTest.php new file mode 100644 index 0000000..c802194 --- /dev/null +++ b/tests/Feature/Panel/EnvironmentBannerTest.php @@ -0,0 +1,68 @@ +seed(RolePermissionSeeder::class); + $this->actingAs($this->userWithRoles('admin')); + } + + protected function setEnvironment(string $environment): void + { + $this->app['env'] = $environment; + } + + #[Test] + public function nothing_is_shown_in_a_local_install(): void + { + $this->setEnvironment('local'); + + $this->get('/admin') + ->assertOk() + ->assertDontSee(__('panel.environment.production')) + ->assertDontSeeText('bg-danger-600', escape: false); + } + + #[Test] + public function production_says_so_in_the_loudest_colour(): void + { + $this->setEnvironment('production'); + + $this->get('/admin') + ->assertOk() + ->assertSee(__('panel.environment.production')); + } + + #[Test] + public function anything_else_names_itself(): void + { + $this->setEnvironment('staging'); + + $this->get('/admin') + ->assertOk() + ->assertSee(__('panel.environment.other', ['name' => 'staging'])); + } +} diff --git a/tests/Feature/Panel/EveryPanelPageRendersTest.php b/tests/Feature/Panel/EveryPanelPageRendersTest.php new file mode 100644 index 0000000..4980221 --- /dev/null +++ b/tests/Feature/Panel/EveryPanelPageRendersTest.php @@ -0,0 +1,77 @@ +seed(RolePermissionSeeder::class); + $this->actingAs($this->userWithRoles('super_admin')); + + $failures = []; + + foreach (Filament::getPanel('admin')->getResources() as $resource) { + /** @var class-string $resource */ + if (! array_key_exists('index', $resource::getPages())) { + continue; + } + + $failures = [...$failures, ...$this->visit($resource::getUrl('index'), $resource)]; + } + + $this->assertSame([], $failures, implode("\n", $failures)); + } + + #[Test] + public function every_custom_page_opens(): void + { + $this->seed(RolePermissionSeeder::class); + $this->actingAs($this->userWithRoles('super_admin')); + + $failures = []; + + foreach (Filament::getPanel('admin')->getPages() as $page) { + $failures = [...$failures, ...$this->visit($page::getUrl(), $page)]; + } + + $this->assertSame([], $failures, implode("\n", $failures)); + } + + /** + * @return array + */ + protected function visit(string $url, string $subject): array + { + try { + $status = $this->withoutExceptionHandling()->get($url)->status(); + } catch (Throwable $e) { + return ["{$subject} — {$url}\n ".$e::class.': '.$e->getMessage()]; + } + + return $status === 200 ? [] : ["{$subject} — {$url} answered {$status}"]; + } +} diff --git a/tests/Feature/Panel/MediaLibraryTest.php b/tests/Feature/Panel/MediaLibraryTest.php new file mode 100644 index 0000000..9af2beb --- /dev/null +++ b/tests/Feature/Panel/MediaLibraryTest.php @@ -0,0 +1,148 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + Storage::fake(config('media.disk')); + } + + #[Test] + public function the_library_lists_what_has_been_uploaded(): void + { + Project::factory()->create(['cover_image' => 'projects/una.jpg']); + + $this->actingAs($this->userWithRoles('admin')); + + $this->get(MediaResource::getUrl('index')) + ->assertSuccessful() + ->assertSee('projects/una.jpg'); + } + + #[Test] + public function nobody_uploads_from_the_library(): void + { + $this->actingAs($this->userWithRoles('admin')); + + // Files arrive by being uploaded on a record, not from here. + $this->assertFalse(MediaResource::canCreate()); + } + + #[Test] + public function a_file_something_points_at_cannot_be_deleted(): void + { + Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']); + $media = Media::query()->where('path', 'projects/en-uso.jpg')->sole(); + + $admin = $this->userWithRoles('admin'); + + // Deleting it would put a broken image on the public site, which is + // exactly what the health check exists to complain about. + $this->assertTrue($media->isReferenced()); + $this->assertFalse($admin->can('delete', $media)); + } + + #[Test] + public function a_file_nothing_points_at_can(): void + { + $media = Media::factory()->create(['path' => 'projects/huerfana.jpg']); + + $this->assertFalse($media->isReferenced()); + $this->assertTrue($this->userWithRoles('admin')->can('delete', $media)); + } + + #[Test] + public function the_weight_counts_the_derived_versions_too(): void + { + $media = Media::factory()->create([ + 'size' => 1000, + 'variants' => [ + ['format' => 'webp', 'width' => 400, 'path' => 'a-400.webp', 'size' => 300], + ['format' => 'webp', 'width' => 800, 'path' => 'a-800.webp', 'size' => 700], + ], + ]); + + // The number that explains where the disk went. + $this->assertSame(2000, $media->totalSize()); + } + + #[Test] + public function a_viewer_may_look_but_not_tidy(): void + { + $media = Media::factory()->create(); + $viewer = $this->userWithRoles('viewer'); + + $this->assertTrue($viewer->can('media.view_any')); + $this->assertFalse($viewer->can('delete', $media)); + } + + #[Test] + public function a_technology_can_carry_a_logo(): void + { + $technology = Technology::factory()->create([ + 'name' => 'Laravel', + 'logo' => 'technologies/laravel.svg', + ]); + + // Registered like any other upload: same media row, same processing. + $this->assertSame(1, Media::query()->where('path', 'technologies/laravel.svg')->count()); + $this->assertSame('technologies/laravel.svg', $technology->logo); + } + + #[Test] + public function the_logo_reaches_the_frontend_with_the_name_as_its_alt(): void + { + Technology::factory()->create([ + 'name' => 'Rust', + 'slug' => 'rust', + 'radar_ring' => 'adopt', + 'logo' => 'technologies/rust.svg', + ]); + + $data = $this->withHeaders(['Accept-Language' => 'es']) + ->getJson('/api/public/v1/radar') + ->assertOk() + ->json('data.0'); + + // A logo's description is the name of the thing, so there is nothing + // to type and nothing to get wrong. + $this->assertSame('Rust', $data['logo']['alt']); + $this->assertStringContainsString('technologies/rust.svg', $data['logo']['url']); + } + + #[Test] + public function the_technology_editor_still_opens(): void + { + $technology = Technology::factory()->create(['logo' => 'technologies/x.svg']); + + $this->actingAs($this->userWithRoles('admin')); + + $this->get(TechnologyResource::getUrl('edit', ['record' => $technology]))->assertSuccessful(); + } +} diff --git a/tests/Feature/Panel/MissingUrlsTest.php b/tests/Feature/Panel/MissingUrlsTest.php new file mode 100644 index 0000000..174f3dc --- /dev/null +++ b/tests/Feature/Panel/MissingUrlsTest.php @@ -0,0 +1,249 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function the_action_writes_the_redirect_and_closes_the_report(): void + { + $this->actingAs($this->userWithRoles('super_admin')); + + $missing = MissingUrl::factory()->create([ + 'path' => '/proyectos/nombre-viejo', + 'locale' => 'es', + ]); + + Livewire::test(ListMissingUrls::class) + ->callTableAction('redirect', $missing, data: [ + 'to' => '/proyectos/nombre-nuevo', + 'status' => 301, + 'locale' => 'es', + ]) + ->assertHasNoTableActionErrors(); + + $this->assertDatabaseHas('redirects', [ + 'from' => '/proyectos/nombre-viejo', + 'to' => '/proyectos/nombre-nuevo', + 'status' => 301, + 'locale' => 'es', + ]); + + $this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status); + } + + /** + * The destination goes through the same normalisation as a hand-written + * rule, so an address typed with a trailing slash does not become a + * redirect that never matches. + */ + #[Test] + public function the_destination_is_normalised(): void + { + $this->actingAs($this->userWithRoles('super_admin')); + + $missing = MissingUrl::factory()->create(['path' => '/algo', 'locale' => 'es']); + + Livewire::test(ListMissingUrls::class) + ->callTableAction('redirect', $missing, data: [ + 'to' => '/servicios/algo/', + 'status' => 301, + 'locale' => 'es', + ]); + + $this->assertDatabaseHas('redirects', ['to' => '/servicios/algo']); + } + + #[Test] + public function ignoring_hides_an_entry_without_stopping_the_count(): void + { + $this->actingAs($this->userWithRoles('super_admin')); + + $missing = MissingUrl::factory()->create([ + 'path' => '/ruido', + 'locale' => 'es', + 'hits' => 1, + ]); + + Livewire::test(ListMissingUrls::class) + ->callTableAction('ignore', $missing); + + $this->assertSame(MissingUrlStatus::Ignored, $missing->refresh()->status); + + // Still counted: ignoring is a decision about the list, not about + // whether the address is being asked for. + MissingUrl::report('/ruido', 'es'); + + $missing->refresh(); + + $this->assertSame(2, (int) $missing->hits); + + // And it stays ignored. These addresses are requested constantly — if + // the next request revived them, the button would never work twice. + $this->assertSame(MissingUrlStatus::Ignored, $missing->status); + } + + /** + * Gated by the redirect permissions rather than a set of its own, so a + * role that cannot do anything about a broken link does not see the list + * of broken links either. + */ + #[Test] + public function a_viewer_cannot_open_the_list(): void + { + $this->actingAs($this->userWithRoles('viewer')); + + $this->get(MissingUrlResource::getUrl('index'))->assertForbidden(); + } + + #[Test] + public function an_editor_can_open_it(): void + { + $this->actingAs($this->userWithRoles('editor')); + + $this->get(MissingUrlResource::getUrl('index'))->assertSuccessful(); + } + + #[Test] + public function nobody_creates_one_by_hand(): void + { + $this->assertFalse(MissingUrlResource::canCreate()); + } + + #[Test] + public function the_health_check_counts_what_is_still_broken(): void + { + MissingUrl::factory()->create(['path' => '/roto', 'locale' => 'es', 'hits' => 12]); + + // A single request is as likely to be a scanner as a person. + MissingUrl::factory()->create(['path' => '/una-vez', 'locale' => 'es', 'hits' => 1]); + + // Already dealt with. + MissingUrl::factory()->resolved()->create(['path' => '/arreglado', 'locale' => 'es', 'hits' => 30]); + MissingUrl::factory()->ignored()->create(['path' => '/ruido', 'locale' => 'es', 'hits' => 30]); + + $finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls'); + + $this->assertInstanceOf(Finding::class, $finding); + $this->assertSame(1, $finding->count); + $this->assertSame(Severity::Warning, $finding->severity); + } + + /** + * An address our own pages link to is not somebody else's stale bookmark: + * it is a fault in what we publish, and it will keep producing 404s. + */ + #[Test] + public function a_dead_link_on_our_own_site_is_an_error_not_a_warning(): void + { + MissingUrl::factory()->internal()->create([ + 'path' => '/roto', + 'locale' => 'es', + 'hits' => 4, + ]); + + $finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls'); + + $this->assertSame(Severity::Error, $finding->severity); + } + + #[Test] + public function pruning_forgets_one_off_scans_and_keeps_the_rest(): void + { + MissingUrl::factory()->create([ + 'path' => '/una-vez', 'locale' => 'es', + 'hits' => 1, 'last_seen_at' => now()->subDays(60), + ]); + + MissingUrl::factory()->create([ + 'path' => '/repetida', 'locale' => 'es', + 'hits' => 9, 'last_seen_at' => now()->subDays(60), + ]); + + MissingUrl::factory()->create([ + 'path' => '/reciente', 'locale' => 'es', + 'hits' => 1, 'last_seen_at' => now()->subDay(), + ]); + + // A decision already made must not come back next week as though it + // had never been made. + MissingUrl::factory()->ignored()->create([ + 'path' => '/ignorada', 'locale' => 'es', + 'hits' => 1, 'last_seen_at' => now()->subDays(60), + ]); + + $this->artisan('content:prune')->assertExitCode(0); + + $this->assertDatabaseMissing('missing_urls', ['path' => '/una-vez']); + $this->assertDatabaseHas('missing_urls', ['path' => '/repetida']); + $this->assertDatabaseHas('missing_urls', ['path' => '/reciente']); + $this->assertDatabaseHas('missing_urls', ['path' => '/ignorada']); + } + + #[Test] + public function a_dry_run_deletes_nothing(): void + { + MissingUrl::factory()->create([ + 'path' => '/una-vez', 'locale' => 'es', + 'hits' => 1, 'last_seen_at' => now()->subDays(60), + ]); + + $this->artisan('content:prune --dry-run')->assertExitCode(0); + + $this->assertDatabaseHas('missing_urls', ['path' => '/una-vez']); + } + + /** + * A rule typed straight into the redirects screen closes the report too: + * the two lists must never disagree about whether an address is answered. + */ + #[Test] + public function a_hand_written_redirect_also_closes_the_report(): void + { + $missing = MissingUrl::factory()->create(['path' => '/a-mano', 'locale' => 'es']); + + Redirect::create([ + 'from' => '/a-mano', + 'to' => '/destino', + 'status' => 301, + 'locale' => 'es', + 'is_enabled' => true, + ]); + + $this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status); + } +} diff --git a/tests/Feature/Panel/PanelAccessTest.php b/tests/Feature/Panel/PanelAccessTest.php new file mode 100644 index 0000000..6fb7840 --- /dev/null +++ b/tests/Feature/Panel/PanelAccessTest.php @@ -0,0 +1,140 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function a_guest_is_sent_to_the_login_page(): void + { + $this->get('/admin')->assertRedirect(); + $this->get('/admin/login')->assertOk()->assertSee(__('pireon.sign_in')); + } + + #[Test] + public function the_login_page_asks_for_credentials_and_nothing_else(): void + { + $response = $this->get('/admin/login')->assertOk(); + + // The identity server is meant to be invisible: whoever signs in sees + // a form, not a hand-off to somewhere else. + $response->assertSee('data.email', escape: false); + $response->assertSee('data.password', escape: false); + $response->assertDontSee(route('auth.redirect'), escape: false); + $response->assertDontSee((string) config('pireon.server'), escape: false); + } + + #[Test] + public function a_user_without_the_panel_access_permission_is_refused(): void + { + // A real identity, but no local role has been granted yet. + $user = $this->userWithRoles([]); + + $this->actingAs($user)->get('/admin')->assertForbidden(); + } + + #[Test] + public function a_viewer_can_open_the_panel(): void + { + $user = $this->userWithRoles('viewer'); + + $this->actingAs($user)->get('/admin')->assertSuccessful(); + } + + #[Test] + public function a_deactivated_user_cannot_open_the_panel(): void + { + $user = $this->userWithRoles('viewer', ['is_active' => false]); + + $this->actingAs($user)->get('/admin')->assertForbidden(); + } + + #[Test] + public function resource_visibility_follows_local_permissions(): void + { + $viewer = $this->userWithRoles('viewer'); + $admin = $this->userWithRoles('admin'); + + $this->actingAs($viewer); + $this->assertTrue(PageResource::canViewAny()); + $this->assertTrue(CategoryResource::canViewAny()); + $this->assertFalse(UserResource::canViewAny()); + $this->assertFalse(RoleResource::canViewAny()); + + $this->actingAs($admin); + $this->assertTrue(UserResource::canViewAny()); + $this->assertTrue(RoleResource::canViewAny()); + } + + #[Test] + public function resource_index_pages_render(): void + { + Page::factory()->count(3)->create(); + Category::factory()->count(2)->create(); + + $admin = $this->userWithRoles('admin'); + + $this->actingAs($admin); + + $this->get(PageResource::getUrl('index'))->assertSuccessful(); + $this->get(CategoryResource::getUrl('index'))->assertSuccessful(); + $this->get(UserResource::getUrl('index'))->assertSuccessful(); + $this->get(RoleResource::getUrl('index'))->assertSuccessful(); + } + + #[Test] + public function the_panel_renders_in_the_users_language(): void + { + $user = $this->userWithRoles('viewer', ['locale' => 'ca']); + + $this->actingAs($user) + ->get(PageResource::getUrl('index')) + ->assertSuccessful() + ->assertSee('Pàgines'); + } + + #[Test] + public function the_language_switcher_persists_the_choice(): void + { + $user = $this->userWithRoles('viewer', ['locale' => 'es']); + + $this->actingAs($user) + ->from(PageResource::getUrl('index')) + ->get(route('locale.switch', 'en')) + ->assertRedirect(); + + $this->assertSame('en', $user->fresh()->locale); + $this->assertSame('en', session(config('locales.session_key'))); + } + + #[Test] + public function an_unsupported_locale_is_rejected(): void + { + $user = $this->userWithRoles('viewer'); + + $this->actingAs($user)->get(route('locale.switch', 'de'))->assertNotFound(); + } +} diff --git a/tests/Feature/Panel/PanelToolsTest.php b/tests/Feature/Panel/PanelToolsTest.php new file mode 100644 index 0000000..39dd008 --- /dev/null +++ b/tests/Feature/Panel/PanelToolsTest.php @@ -0,0 +1,166 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + } + + #[Test] + public function the_bin_gathers_everything_deleted(): void + { + Page::factory()->create(['title' => ['es' => 'Borrada']])->delete(); + Project::factory()->create(['title' => ['es' => 'Borrado']])->delete(); + + $this->actingAs($this->userWithRoles('admin')); + + // Recovering something used to mean remembering which resource it had + // been in and then turning on a filter. + $this->get(Trash::getUrl()) + ->assertSuccessful() + ->assertSee('Borrada') + ->assertSee('Borrado'); + } + + #[Test] + public function something_can_be_put_back_from_the_bin(): void + { + $page = Page::factory()->create(); + $page->delete(); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(Trash::class) + ->call('restoreRecord', Page::class, $page->id); + + $this->assertNull($page->fresh()->deleted_at); + } + + #[Test] + public function the_bin_refuses_a_model_it_does_not_list(): void + { + $this->actingAs($this->userWithRoles('admin')); + + // An id and a class name arriving from the browser must not be able to + // reach anything the page did not declare. + Livewire::test(Trash::class) + ->call('restoreRecord', User::class, 1) + ->assertOk(); + + $this->assertTrue(true); + } + + #[Test] + public function a_viewer_sees_only_what_they_may_see(): void + { + Application::factory()->create()->delete(); + Page::factory()->create(['title' => ['es' => 'Una página']])->delete(); + + $this->actingAs($this->userWithRoles('viewer')); + + // A viewer has no business reading a stranger's message, in the bin or + // anywhere else. + $this->get(Trash::getUrl()) + ->assertSuccessful() + ->assertSee('Una página') + ->assertDontSee(__('panel.applications.plural')); + } + + #[Test] + public function a_preview_link_points_at_the_record_it_was_made_for(): void + { + $project = Project::factory()->draft()->create([ + 'title' => ['es' => 'Borrador'], + 'slug' => ['es' => 'borrador'], + ]); + + $url = PreviewLink::for($project, 'project', 'es'); + + $this->assertStringContainsString('/proyectos/borrador', $url); + $this->assertStringContainsString('preview=', $url); + + $token = urldecode((string) parse_url($url, PHP_URL_QUERY)); + $token = str_replace('preview=', '', $token); + + $payload = PreviewLink::open($token); + + $this->assertSame('project', $payload['type']); + $this->assertSame($project->id, $payload['id']); + $this->assertSame('es', $payload['locale']); + } + + #[Test] + public function a_preview_link_opens_a_draft_on_the_api(): void + { + $project = Project::factory()->draft()->create([ + 'title' => ['es' => 'Borrador'], + 'slug' => ['es' => 'borrador'], + ]); + + $url = PreviewLink::for($project, 'project', 'es'); + $token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY))); + + $this->withHeaders(['Accept-Language' => 'es']) + ->getJson('/api/public/v1/projects/borrador') + ->assertNotFound(); + + $this->withHeaders(['Accept-Language' => 'es']) + ->getJson('/api/public/v1/projects/borrador?preview='.urlencode($token)) + ->assertOk(); + } + + #[Test] + public function a_forged_or_expired_link_opens_nothing(): void + { + $this->assertNull(PreviewLink::open('no-es-un-token')); + + $project = Project::factory()->draft()->create([ + 'title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador'], + ]); + + $url = PreviewLink::for($project, 'project', 'es'); + $token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY))); + + $this->travel(PreviewLink::HOURS + 1)->hours(); + + // Short-lived on purpose: one forwarded in an e-mail thread stops + // working rather than living for ever. + $this->assertNull(PreviewLink::open($token)); + } + + #[Test] + public function a_record_with_no_slug_in_a_language_has_no_link_for_it(): void + { + $project = Project::factory()->draft()->create(['slug' => ['es' => 'solo-es']]); + + $this->assertNotNull(PreviewLink::for($project, 'project', 'es')); + $this->assertNull(PreviewLink::for($project, 'project', 'ca')); + } +} diff --git a/tests/Feature/Panel/ReviewsTest.php b/tests/Feature/Panel/ReviewsTest.php new file mode 100644 index 0000000..fcb78af --- /dev/null +++ b/tests/Feature/Panel/ReviewsTest.php @@ -0,0 +1,330 @@ +seed(RolePermissionSeeder::class); + Notification::fake(); + } + + /** + * Somebody who can write and not publish — the shipped `writer` role, + * which is the whole reason this feature has anywhere to happen. + */ + protected function writer(): User + { + return $this->userWithRoles('writer'); + } + + protected function draft(?User $author = null): Page + { + return Page::factory()->create([ + 'status' => PageStatus::Draft, + 'published_at' => null, + 'author_id' => $author?->id, + ]); + } + + #[Test] + public function asking_for_a_review_records_who_asked_and_what_they_said(): void + { + $editor = $this->userWithRoles('editor'); + $page = $this->draft($editor); + + $page->requestReview(null, 'Falta la foto de portada', $editor); + + $page->refresh(); + + $this->assertTrue($page->isAwaitingReview()); + $this->assertSame($editor->id, $page->review_requested_by); + + $comment = $page->reviewComments()->first(); + + $this->assertSame(ReviewEvent::Requested, $comment->event); + $this->assertSame('Falta la foto de portada', $comment->body); + $this->assertSame($editor->name, $comment->author()); + } + + /** + * The status enum already had a `Review` value, shown in the table and + * offered as a filter. It has to keep telling the truth. + */ + #[Test] + public function a_page_in_review_says_so_in_its_status(): void + { + $page = $this->draft(); + + $page->requestReview(); + + $this->assertSame(PageStatus::Review, $page->refresh()->status); + } + + /** + * Nothing about asking is a new permission: being allowed to edit + * something is already the right to say you have finished with it. + */ + #[Test] + public function somebody_who_cannot_publish_can_still_ask(): void + { + $writer = $this->writer(); + + $this->assertTrue($writer->cannot('pages.publish')); + $this->assertTrue($writer->can('pages.update')); + + $page = $this->draft($writer); + $page->requestReview(null, null, $writer); + + $this->assertTrue($page->refresh()->isAwaitingReview()); + } + + /** + * The distinction the catalogue always made and no role exercised: a + * separate `publish` permission is only meaningful if somebody holds + * `update` without it. + */ + #[Test] + public function the_shipped_writer_role_is_the_one_this_exists_for(): void + { + $writer = $this->writer(); + + $this->assertTrue($writer->can('pages.update')); + $this->assertTrue($writer->can('projects.update')); + $this->assertTrue($writer->can('services.update')); + + $this->assertTrue($writer->cannot('pages.publish')); + $this->assertTrue($writer->cannot('projects.publish')); + $this->assertTrue($writer->cannot('services.publish')); + + // Same reason the viewer does not have it: writing for the site and + // reading strangers' contact details are different jobs. + $this->assertTrue($writer->cannot('applications.view_any')); + } + + #[Test] + public function everybody_who_could_publish_it_is_told(): void + { + $writer = $this->writer(); + $admin = $this->userWithRoles('admin'); + $viewer = $this->userWithRoles('viewer'); + + $page = $this->draft($writer); + $page->requestReview(null, 'Listo', $writer); + + Notification::assertSentTo($admin, ReviewRequested::class); + + // Being told about work you could never act on is noise. + Notification::assertNotSentTo($viewer, ReviewRequested::class); + Notification::assertNotSentTo($writer, ReviewRequested::class); + } + + /** + * Naming somebody addresses the request, and then it is theirs alone — + * otherwise "assigned to Ana" would mean nothing. + */ + #[Test] + public function naming_a_reviewer_tells_only_them(): void + { + $writer = $this->writer(); + $ana = $this->userWithRoles('admin'); + $other = $this->userWithRoles('admin'); + + $page = $this->draft($writer); + $page->requestReview($ana, null, $writer); + + Notification::assertSentTo($ana, ReviewRequested::class); + Notification::assertNotSentTo($other, ReviewRequested::class); + } + + #[Test] + public function sending_it_back_tells_the_person_who_asked(): void + { + $writer = $this->writer(); + $admin = $this->userWithRoles('admin'); + + $page = $this->draft($writer); + $page->requestReview(null, null, $writer); + + $page->returnForChanges('Cambia el título, es el de otra página', $admin); + + $page->refresh(); + + $this->assertFalse($page->isAwaitingReview()); + $this->assertSame(PageStatus::Draft, $page->status); + + Notification::assertSentTo($writer, ReviewReturned::class); + + $comment = $page->reviewComments()->first(); + $this->assertSame(ReviewEvent::Returned, $comment->event); + $this->assertSame('Cambia el título, es el de otra página', $comment->body); + } + + /** + * The loop closes on the state, not on a button, so no route can leave + * something live and still marked as waiting for somebody. + */ + #[Test] + public function publishing_closes_the_review_however_it_was_published(): void + { + $page = $this->draft(); + $page->requestReview(); + + // Straight to the model, as the form and the table action both do. + $page->update(['status' => PageStatus::Published, 'published_at' => now()]); + + $page->refresh(); + + $this->assertFalse($page->isAwaitingReview()); + $this->assertNull($page->reviewer_id); + $this->assertSame( + ReviewEvent::Published, + $page->reviewComments()->first()->event, + ); + } + + /** + * Scheduling is not publishing: a page whose date is next week is not on + * the site yet, so nobody's review is over. + */ + #[Test] + public function a_future_date_does_not_close_the_review(): void + { + $page = $this->draft(); + $page->requestReview(); + + $page->update([ + 'status' => PageStatus::Published, + 'published_at' => now()->addWeek(), + ]); + + $this->assertTrue($page->refresh()->isAwaitingReview()); + } + + #[Test] + public function the_review_columns_stay_out_of_the_change_history(): void + { + $page = $this->draft(); + + $page->requestReview(null, 'Nota'); + + // The thread already says this in words. Repeating it as a diff would + // bury the edits somebody actually came looking for. + $fields = $page->revisions()->get()->flatMap(fn ($revision) => $revision->fields()); + + $this->assertNotContains('review_requested_at', $fields); + $this->assertNotContains('reviewer_id', $fields); + } + + #[Test] + public function an_unaddressed_review_is_in_everybodys_queue(): void + { + $admin = $this->userWithRoles('admin'); + $ana = $this->userWithRoles('admin'); + + $this->draft()->requestReview(); + $this->draft()->requestReview($ana); + + // The unaddressed one, plus nothing addressed to somebody else. + $this->assertSame(1, Page::query()->awaitingReviewBy($admin)->count()); + + // Ana sees hers and the unaddressed one. + $this->assertSame(2, Page::query()->awaitingReviewBy($ana)->count()); + } + + #[Test] + public function it_works_the_same_for_projects_and_services(): void + { + $project = Project::factory()->create(['published_at' => null]); + $service = Service::factory()->create(['is_published' => false, 'published_at' => null]); + + $project->requestReview(); + $service->requestReview(); + + $this->assertTrue($project->refresh()->isAwaitingReview()); + $this->assertTrue($service->refresh()->isAwaitingReview()); + + $project->update(['published_at' => now()]); + $service->update(['is_published' => true, 'published_at' => now()]); + + $this->assertFalse($project->refresh()->isAwaitingReview()); + $this->assertFalse($service->refresh()->isAwaitingReview()); + } + + #[Test] + public function a_comment_changes_nothing_but_is_recorded(): void + { + $page = $this->draft(); + $page->requestReview(); + + $page->comment('¿La foto la tienes tú?'); + + $this->assertTrue($page->refresh()->isAwaitingReview()); + $this->assertSame( + ReviewEvent::Commented, + $page->reviewComments()->first()->event, + ); + } + + #[Test] + public function the_health_check_finds_a_handover_nobody_answered(): void + { + $fresh = $this->draft(); + $fresh->requestReview(); + + $stale = $this->draft(); + $stale->requestReview(); + $stale->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly(); + + $finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-reviews'); + + $this->assertInstanceOf(Finding::class, $finding); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function the_check_is_hidden_from_somebody_who_could_not_act_on_it(): void + { + $page = $this->draft(); + $page->requestReview(); + $page->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly(); + + $keys = fn (User $user) => app(HealthReport::class)->runFor($user)->pluck('key'); + + $this->assertContains('stale-reviews', $keys($this->userWithRoles('admin'))); + $this->assertNotContains('stale-reviews', $keys($this->userWithRoles('viewer'))); + } +} diff --git a/tests/Feature/Panel/RevisionsTest.php b/tests/Feature/Panel/RevisionsTest.php new file mode 100644 index 0000000..3be9efa --- /dev/null +++ b/tests/Feature/Panel/RevisionsTest.php @@ -0,0 +1,198 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + } + + #[Test] + public function creating_something_is_recorded(): void + { + $page = Page::factory()->create(); + + $this->assertSame('created', $page->revisions()->sole()->event); + } + + #[Test] + public function it_records_who_made_the_change_by_name(): void + { + $user = $this->userWithRoles('admin', ['name' => 'María']); + $this->actingAs($user); + + $page = Page::factory()->create(); + $page->update(['title' => ['es' => 'Nuevo título']]); + + $revision = $page->revisions()->where('event', 'updated')->sole(); + + $this->assertSame($user->id, $revision->user_id); + + // The name is kept beside the id because people leave and accounts get + // deleted, and "somebody published this" is worth less than a name. + $this->assertSame('María', $revision->user_name); + $this->assertSame('María', $revision->author()); + } + + #[Test] + public function a_change_made_by_no_one_says_so(): void + { + $page = Page::factory()->create(); + $page->update(['title' => ['es' => 'Cambiado por un comando']]); + + // The scheduler and the console have no user, and blaming the last + // person who logged in would be a lie. + $this->assertNull($page->revisions()->first()->user_id); + $this->assertSame(__('panel.revisions.system'), $page->revisions()->first()->author()); + } + + #[Test] + public function publishing_reads_differently_from_editing(): void + { + $this->actingAs($this->userWithRoles('admin')); + + $page = Page::factory()->create([ + 'status' => PageStatus::Draft, + 'published_at' => null, + ]); + + $page->update(['status' => PageStatus::Published, 'published_at' => now()->subMinute()]); + + $this->assertSame('published', $page->revisions()->first()->event); + + $page->update(['status' => PageStatus::Draft]); + + // A history where publishing looks like any other save hides exactly + // the thing somebody came looking for. + $this->assertSame('unpublished', $page->revisions()->first()->event); + } + + #[Test] + public function it_keeps_the_old_value_beside_the_new_one(): void + { + $page = Page::factory()->create(['title' => ['es' => 'Antes']]); + + $page->update(['title' => ['es' => 'Después']]); + + $revision = $page->revisions()->where('event', 'updated')->sole(); + $diff = $revision->diff('title'); + + $this->assertSame(['es' => 'Antes'], $diff['old']); + $this->assertSame(['es' => 'Después'], $diff['new']); + } + + #[Test] + public function it_records_only_what_changed(): void + { + $page = Page::factory()->create(['title' => ['es' => 'Uno'], 'is_featured' => false]); + + $page->update(['is_featured' => true]); + + $revision = $page->revisions()->where('event', 'updated')->sole(); + + // Storing whole records would make the history larger than the content + // it describes. + $this->assertSame(['is_featured'], $revision->fields()); + } + + #[Test] + public function saving_without_changing_anything_records_nothing(): void + { + $page = Page::factory()->create(); + $before = $page->revisions()->count(); + + $page->update(['title' => $page->getTranslations('title')]); + + $this->assertSame($before, $page->revisions()->count()); + } + + #[Test] + public function a_previous_state_can_be_put_back(): void + { + $page = Page::factory()->create(['title' => ['es' => 'Original']]); + $page->update(['title' => ['es' => 'Equivocado']]); + + $revision = $page->revisions()->where('event', 'updated')->sole(); + + $page->forceFill($revision->previousValues())->save(); + + $this->assertSame('Original', $page->fresh()->getTranslation('title', 'es')); + + // Restoring is itself a change, so the way back is never lost. + $this->assertSame('updated', $page->fresh()->revisions()->first()->event); + } + + #[Test] + public function deleting_and_erasing_are_told_apart(): void + { + $page = Page::factory()->create(); + + $page->delete(); + $this->assertSame('deleted', $page->revisions()->first()->event); + + $page->restore(); + $this->assertSame('restored', $page->revisions()->first()->event); + + $page->forceDelete(); + + // The record is gone; its history says what happened to it. + $this->assertSame('erased', Revision::query() + ->where('revisionable_type', $page->getMorphClass()) + ->where('revisionable_id', $page->id) + // By id, not by timestamp: these all land in the same second. + ->orderByDesc('id') + ->first() + ->event); + } + + #[Test] + public function the_history_shows_on_the_record(): void + { + $this->actingAs($this->userWithRoles('admin')); + + $page = Page::factory()->create(); + $page->update(['title' => ['es' => 'Cambiado']]); + + $this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful(); + } + + #[Test] + public function every_content_type_keeps_one(): void + { + // Pinned: the factory randomises this one, and updating a value to + // what it already was is correctly not a change. + $project = Project::factory()->create(['is_featured' => false]); + + $project->update(['is_featured' => true]); + + $this->assertSame(2, $project->revisions()->count()); + } +} diff --git a/tests/Feature/Panel/SettingsResourceTest.php b/tests/Feature/Panel/SettingsResourceTest.php new file mode 100644 index 0000000..5a839c4 --- /dev/null +++ b/tests/Feature/Panel/SettingsResourceTest.php @@ -0,0 +1,116 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function the_list_renders_whatever_shape_a_setting_has(): void + { + Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Hola']]); + Setting::set('un.escalar', 'texto'); + Setting::set('una.lista', [1, 2, 3]); + + $this->actingAs($this->userWithRoles('super_admin')); + + $this->get(SettingsResource::getUrl('index'))->assertSuccessful(); + } + + #[Test] + public function a_nested_setting_survives_a_round_trip(): void + { + $value = [ + 'enabled' => true, + 'message' => ['es' => 'Volvemos en un rato', 'ca' => 'Tornem de seguida'], + 'until' => null, + ]; + + Setting::set('site.maintenance', $value); + + $this->actingAs($this->userWithRoles('super_admin')); + + Livewire::test(EditSettings::class, ['record' => 'site.maintenance']) + ->call('save') + ->assertHasNoFormErrors(); + + // Opening and saving without touching anything must leave every + // translation exactly as it was. A key/value grid would have written + // back the word "Array" here. + // assertEquals, not assertSame: Postgres stores these as jsonb, + // which does not preserve object key order. Every value here is a map + // whose order carries no meaning. + $this->assertEquals($value, Setting::get('site.maintenance')); + } + + #[Test] + public function a_nested_value_can_be_edited(): void + { + Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Antiguo']]); + + $this->actingAs($this->userWithRoles('super_admin')); + + Livewire::test(EditSettings::class, ['record' => 'site.maintenance']) + ->fillForm(['value' => json_encode(['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']])]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertEquals( + ['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']], + Setting::get('site.maintenance'), + ); + } + + #[Test] + public function a_typo_is_refused_rather_than_stored(): void + { + Setting::set('site.maintenance', ['enabled' => false]); + + $this->actingAs($this->userWithRoles('super_admin')); + + Livewire::test(EditSettings::class, ['record' => 'site.maintenance']) + ->fillForm(['value' => '{"enabled": tru}']) + ->call('save') + ->assertHasFormErrors(['value']); + + // Invalid JSON must not reach a column the whole application reads. + $this->assertSame(['enabled' => false], Setting::get('site.maintenance')); + } + + #[Test] + public function an_editor_may_look_but_not_touch(): void + { + Setting::set('site.maintenance', ['enabled' => false]); + + $editor = $this->userWithRoles('editor'); + + $this->assertTrue($editor->can('settings.view')); + $this->assertFalse($editor->can('settings.update')); + } +} diff --git a/tests/Feature/Panel/ShowcaseResourcesTest.php b/tests/Feature/Panel/ShowcaseResourcesTest.php new file mode 100644 index 0000000..f0f2810 --- /dev/null +++ b/tests/Feature/Panel/ShowcaseResourcesTest.php @@ -0,0 +1,127 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + } + + #[Test] + public function every_editor_screen_opens(): void + { + $client = Client::factory()->create(); + $service = Service::factory()->create(); + $testimonial = Testimonial::factory()->create(['client_id' => $client->id]); + $talk = Talk::factory()->create(); + + $this->actingAs($this->userWithRoles('admin')); + + foreach ([ + [ServiceResource::class, $service], + [ClientResource::class, $client], + [TestimonialResource::class, $testimonial], + [TalkResource::class, $talk], + ] as [$resource, $record]) { + $this->get($resource::getUrl('create'))->assertSuccessful(); + $this->get($resource::getUrl('edit', ['record' => $record]))->assertSuccessful(); + } + } + + #[Test] + public function publishing_a_service_is_a_separate_permission_from_writing_one(): void + { + $editor = $this->userWithRoles('editor'); + + // Same reasoning as projects and pages: somebody may write the offer + // without deciding it goes on the website. + $this->assertTrue($editor->can('services.create')); + $this->assertTrue($editor->can('services.publish')); + + $viewer = $this->userWithRoles('viewer'); + $this->assertTrue($viewer->can('services.view_any')); + $this->assertFalse($viewer->can('services.publish')); + $this->assertFalse($viewer->can('services.create')); + } + + #[Test] + public function a_viewer_can_look_at_the_showcase_but_not_change_it(): void + { + $this->actingAs($this->userWithRoles('viewer')); + + foreach ([ServiceResource::class, ClientResource::class, TestimonialResource::class, TalkResource::class] as $resource) { + $this->assertTrue($resource::canViewAny()); + $this->assertFalse($resource::canCreate()); + } + } + + #[Test] + public function deleting_a_client_leaves_its_projects_standing(): void + { + $client = Client::factory()->create(); + $project = Project::factory()->create(['client_id' => $client->id]); + + $client->forceDelete(); + + // The work happened whether or not the organisation is still on file. + $this->assertNotNull($project->fresh()); + $this->assertNull($project->fresh()->client_id); + } + + #[Test] + public function a_talk_shows_on_the_profile_of_whoever_gave_it(): void + { + $talk = Talk::factory()->create(); + $member = Member::factory()->create(); + + $talk->update(['member_id' => $member->id]); + + // Written once, it appears in both places instead of being typed + // twice and drifting. + $this->assertSame(1, $member->talks()->count()); + $this->assertSame($talk->id, $member->talks()->sole()->id); + } + + #[Test] + public function a_service_knows_how_much_work_backs_it(): void + { + $service = Service::factory()->create(); + + $this->assertSame(0, $service->projects()->count()); + + $service->projects()->attach(Project::factory()->published()->create()->id); + + // The number the listing shows in orange when it is zero: a service + // with nothing behind it is a promise. + $this->assertSame(1, $service->projects()->count()); + } +} diff --git a/tests/Feature/Panel/SiteHealthTest.php b/tests/Feature/Panel/SiteHealthTest.php new file mode 100644 index 0000000..6cf957d --- /dev/null +++ b/tests/Feature/Panel/SiteHealthTest.php @@ -0,0 +1,244 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + Storage::fake(config('media.disk')); + } + + protected function finding(string $key): Finding + { + return app(HealthReport::class)->runAll()->firstWhere('key', $key) + ?? throw new \RuntimeException("No finding for {$key}"); + } + + #[Test] + public function a_clean_site_reports_nothing(): void + { + $problems = app(HealthReport::class)->runAll() + ->filter(fn (Finding $f) => $f->severity->isProblem()); + + $this->assertTrue($problems->isEmpty(), 'Expected an empty database to be healthy'); + } + + #[Test] + public function it_finds_a_record_translated_without_a_slug(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'], + 'slug' => ['es' => 'mi-proyecto'], + ]); + + $finding = $this->finding('missing-slugs'); + + // Already broken on the public site: the Catalan alternate either + // vanishes or points at a 404. + $this->assertSame(Severity::Error, $finding->severity); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function it_finds_two_records_answering_at_the_same_url(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Uno'], 'slug' => ['es' => 'repetido'], + ]); + Project::factory()->published()->create([ + 'title' => ['es' => 'Dos'], 'slug' => ['es' => 'repetido'], + ]); + + $finding = $this->finding('duplicate-slugs'); + + // The write rule prevents this, but a slug lives inside JSON so it can + // never be a database constraint — imports and seeders get through. + $this->assertSame(Severity::Error, $finding->severity); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function the_same_slug_in_two_languages_is_not_a_duplicate(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Uno', 'en' => 'One'], + 'slug' => ['es' => 'igual', 'en' => 'igual'], + ]); + + $this->assertSame(Severity::Ok, $this->finding('duplicate-slugs')->severity); + } + + #[Test] + public function it_finds_an_image_whose_file_is_gone(): void + { + Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']); + + $finding = $this->finding('broken-images'); + + // The API keeps serving the URL — it has no reason to doubt it — so + // the only symptom is a broken image, on the other site. + $this->assertSame(Severity::Error, $finding->severity); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function an_image_still_in_the_queue_is_not_broken(): void + { + Media::factory()->create(['path' => 'projects/pending.jpg']); + + // It is early, not broken. That is a different check. + $this->assertSame(Severity::Ok, $this->finding('broken-images')->severity); + } + + #[Test] + public function it_notices_when_nothing_is_processing_images(): void + { + Media::factory()->create([ + 'path' => 'projects/stuck.jpg', + 'created_at' => now()->subHours(3), + ]); + + $finding = $this->finding('stalled-media'); + + // The site keeps working, images just lose their dimensions and + // srcset — which is exactly why nobody would notice. + $this->assertSame(1, $finding->count); + } + + #[Test] + public function it_finds_a_published_image_nobody_described(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Con portada'], + 'slug' => ['es' => 'con-portada'], + 'cover_image' => 'projects/cover.jpg', + 'cover_image_alt' => null, + ]); + + $this->assertSame(1, $this->finding('images-without-alt')->count); + } + + #[Test] + public function it_finds_a_call_left_open_past_its_closing_date(): void + { + Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => now()->subWeek(), + ]); + + $this->assertSame(1, $this->finding('expired-calls')->count); + } + + #[Test] + public function it_counts_people_who_have_been_waiting_too_long(): void + { + Application::factory()->create(['created_at' => now()->subDays(10)]); + Application::factory()->create(['created_at' => now()->subHours(2)]); + + $finding = $this->finding('unanswered-submissions'); + + // Somebody offered to help and heard nothing, which costs more than + // any other fault on the page — hence an error, not a warning. + $this->assertSame(Severity::Error, $finding->severity); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function a_check_is_hidden_from_anyone_who_may_not_see_what_it_counts(): void + { + Application::factory()->create(['created_at' => now()->subDays(10)]); + + $editor = $this->userWithRoles('editor'); + $viewer = $this->userWithRoles('viewer'); + + $report = app(HealthReport::class); + + // Counting submissions is knowing they exist, and who may know that is + // the same question as who may read the inbox. + $this->assertNotNull($report->runFor($editor)->firstWhere('key', 'unanswered-submissions')); + $this->assertNull($report->runFor($viewer)->firstWhere('key', 'unanswered-submissions')); + } + + #[Test] + public function findings_come_back_worst_first(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Sin portada'], + 'slug' => ['es' => 'sin-portada'], + ]); + Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']); + + $severities = app(HealthReport::class)->runAll() + ->map(fn (Finding $f) => $f->severity->weight()) + ->all(); + + $sorted = $severities; + sort($sorted); + + $this->assertSame($sorted, $severities); + } + + #[Test] + public function the_page_renders_for_anyone_who_can_reach_the_panel(): void + { + $this->actingAs($this->userWithRoles('viewer')); + + $this->get(SiteHealth::getUrl())->assertSuccessful(); + } + + #[Test] + public function the_command_fails_a_deploy_when_something_is_broken(): void + { + Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']); + + $this->artisan('site:check')->assertFailed(); + + // A warning-only site can still deploy when the threshold says so. + $this->artisan('site:check', ['--fail-on' => 'never'])->assertSuccessful(); + } + + #[Test] + public function the_command_can_ignore_warnings_and_fail_only_on_faults(): void + { + // A warning, not a fault: nothing a visitor hits. + Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => now()->subWeek(), + ]); + + $this->artisan('site:check', ['--fail-on' => 'error'])->assertSuccessful(); + $this->artisan('site:check')->assertFailed(); + } +} diff --git a/tests/Feature/Panel/StaleTranslationsTest.php b/tests/Feature/Panel/StaleTranslationsTest.php new file mode 100644 index 0000000..e1193e1 --- /dev/null +++ b/tests/Feature/Panel/StaleTranslationsTest.php @@ -0,0 +1,195 @@ +published()->create([ + 'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'], + 'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'], + 'body' => ['es' => 'Texto', 'en' => 'Text', 'ca' => 'Text'], + ]); + } + + #[Test] + public function a_record_nobody_has_edited_is_not_out_of_date(): void + { + $page = $this->translated(); + + // Written in three languages at once: whatever else is true of it, + // none of them is behind another. + $this->assertSame([], TranslationFreshness::for($page)); + } + + #[Test] + public function editing_the_original_leaves_the_other_languages_behind(): void + { + $page = $this->translated(); + + $this->travel(1)->days(); + $page->setTranslation('title', 'es', 'Uno corregido')->save(); + + $stale = TranslationFreshness::for($page->fresh()); + + $this->assertSame(['en', 'ca'], array_keys($stale)); + + // And it says since when, which is what decides how urgent it is. + $this->assertTrue($stale['ca']->isToday()); + } + + #[Test] + public function catching_a_translation_up_clears_it(): void + { + $page = $this->translated(); + + $this->travel(1)->days(); + $page->setTranslation('body', 'es', 'Texto nuevo')->save(); + + $this->travel(1)->days(); + $page->fresh()->setTranslation('body', 'ca', 'Text nou')->save(); + + $stale = TranslationFreshness::for($page->fresh()); + + // Catalan caught up; English never did. + $this->assertArrayNotHasKey('ca', $stale); + $this->assertArrayHasKey('en', $stale); + } + + /** + * A language with nothing in it is not out of date, it is untranslated — + * and there is already a check that says so. Reporting both about one + * record would just make two lists that never empty. + */ + #[Test] + public function a_missing_translation_is_not_a_stale_one(): void + { + $page = Page::factory()->published()->create([ + 'title' => ['es' => 'Uno'], + 'slug' => ['es' => 'uno'], + ]); + + $this->travel(1)->days(); + $page->setTranslation('title', 'es', 'Uno corregido')->save(); + + $this->assertSame([], TranslationFreshness::for($page->fresh())); + } + + /** + * Renaming the Spanish address does not make the Catalan text less true, + * and slugs are the one translatable field that routinely changes on their + * own — counting them would mark half the site out of date the first time + * somebody tidied a URL. + */ + #[Test] + public function renaming_a_slug_does_not_make_a_translation_stale(): void + { + $page = $this->translated(); + + $this->travel(1)->days(); + $page->setTranslation('slug', 'es', 'uno-mejor')->save(); + + $this->assertSame([], TranslationFreshness::for($page->fresh())); + } + + #[Test] + public function editing_a_translation_does_not_make_the_original_stale(): void + { + $page = $this->translated(); + + $this->travel(1)->days(); + $page->setTranslation('title', 'ca', 'U corregit')->save(); + + // The source language is never reported against itself, and touching + // Catalan says nothing about English. + $this->assertSame([], TranslationFreshness::for($page->fresh())); + } + + #[Test] + public function the_health_check_reports_it(): void + { + $page = $this->translated(); + + $this->travel(1)->days(); + $page->setTranslation('body', 'es', 'Texto corregido')->save(); + + $finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-translations'); + + $this->assertInstanceOf(Finding::class, $finding); + $this->assertSame(1, $finding->count); + $this->assertStringContainsString('ca', $finding->samples[0]); + } + + #[Test] + public function it_works_for_every_translated_type(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Proyecto', 'ca' => 'Projecte'], + 'slug' => ['es' => 'proyecto', 'ca' => 'projecte'], + ]); + + $this->travel(1)->days(); + $project->setTranslation('title', 'es', 'Proyecto nuevo')->save(); + + $this->assertArrayHasKey('ca', TranslationFreshness::for($project->fresh())); + } + + /** + * The filter's half: the answer cannot be a SQL condition, so it is worked + * out per record and handed to the query as a list. + */ + #[Test] + public function the_panel_can_ask_for_the_ones_that_fell_behind(): void + { + $behind = $this->translated(); + $fine = $this->translated(); + + $this->travel(1)->days(); + $behind->setTranslation('title', 'es', 'Uno corregido')->save(); + + $ids = TranslationFreshness::staleIds(Page::query()); + + $this->assertContains($behind->id, $ids); + $this->assertNotContains($fine->id, $ids); + } + + /** + * The answer is cached against the record's `updated_at`, so it has to be + * recomputed the moment anything about that record changes. + */ + #[Test] + public function the_cached_answer_does_not_survive_the_next_edit(): void + { + $page = $this->translated(); + + $this->assertSame([], TranslationFreshness::for($page)); + + $this->travel(1)->days(); + $page->setTranslation('title', 'es', 'Uno corregido')->save(); + + $this->assertNotSame([], TranslationFreshness::for($page->fresh())); + } +} diff --git a/tests/Feature/Panel/UserSessionsTest.php b/tests/Feature/Panel/UserSessionsTest.php new file mode 100644 index 0000000..52f7272 --- /dev/null +++ b/tests/Feature/Panel/UserSessionsTest.php @@ -0,0 +1,135 @@ +seed(RolePermissionSeeder::class); + } + + protected function plantSession(User $user, array $attributes = []): UserSession + { + return UserSession::create(array_merge([ + 'id' => Str::random(40), + 'user_id' => $user->id, + 'ip_address' => '203.0.113.10', + 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15', + 'payload' => base64_encode(serialize([])), + 'last_activity' => now()->subMinutes(5)->timestamp, + ], $attributes)); + } + + #[Test] + public function an_admin_sees_the_accounts_open_sessions(): void + { + $target = $this->userWithRoles('editor'); + $this->plantSession($target); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(SessionsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ]) + ->assertSee('203.0.113.10') + ->assertSee('Safari · macOS'); + } + + #[Test] + public function revoking_a_session_deletes_the_row(): void + { + $target = $this->userWithRoles('editor'); + $session = $this->plantSession($target); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(SessionsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ])->callTableAction('revoke', $session); + + $this->assertDatabaseMissing('sessions', ['id' => $session->id]); + } + + /** + * The one row this screen must never offer to end: doing so would sign + * out whoever is looking at it, mid-click. + */ + #[Test] + public function the_viewers_own_current_session_cannot_be_revoked_from_here(): void + { + $admin = $this->userWithRoles('admin'); + $this->actingAs($admin); + + // Force a request so Laravel actually starts a session and this test + // knows the real session id, then plant a row using that same id — + // exactly what the real session driver would have written. + $this->get(EditUser::getUrl(['record' => $admin])); + $currentId = session()->getId(); + + $currentSession = $this->plantSession($admin, ['id' => $currentId]); + $other = $this->plantSession($admin); + + $manager = Livewire::test(SessionsRelationManager::class, [ + 'ownerRecord' => $admin, + 'pageClass' => EditUser::class, + ]); + + $manager->assertTableActionHidden('revoke', $currentSession); + $manager->assertTableActionVisible('revoke', $other); + } + + #[Test] + public function somebody_without_users_update_cannot_see_the_tab(): void + { + $target = $this->userWithRoles('editor'); + + $this->actingAs($this->userWithRoles('viewer')); + + $this->assertFalse(SessionsRelationManager::canViewForRecord($target, EditUser::class)); + } + + #[Test] + public function a_device_with_no_recognisable_browser_or_platform_says_so(): void + { + $target = $this->userWithRoles('editor'); + $this->plantSession($target, ['user_agent' => 'curl/8.4.0']); + + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(SessionsRelationManager::class, [ + 'ownerRecord' => $target, + 'pageClass' => EditUser::class, + ])->assertSee(__('panel.sessions.unknown_device')); + } +} diff --git a/tests/Feature/Site/ContentVersionTest.php b/tests/Feature/Site/ContentVersionTest.php new file mode 100644 index 0000000..cc30a6c --- /dev/null +++ b/tests/Feature/Site/ContentVersionTest.php @@ -0,0 +1,96 @@ +withHeaders(['Accept-Language' => 'es']); + Queue::fake(); + } + + #[Test] + public function the_frontend_is_given_a_version(): void + { + $this->getJson('/api/public/v1/site') + ->assertOk() + ->assertJsonPath('data.content_version', ContentVersion::current()); + } + + #[Test] + public function publishing_something_moves_it(): void + { + $before = ContentVersion::current(); + + Project::factory()->published()->create(); + + $this->assertGreaterThan($before, ContentVersion::current()); + } + + #[Test] + public function a_draft_nobody_can_see_does_not(): void + { + Project::factory()->draft()->create(); + + $before = ContentVersion::current(); + + Project::query()->first()->update(['title' => ['es' => 'Otro borrador']]); + + // Editing something invisible invalidates nobody's cache, and bumping + // on every keystroke of a half-written page would make the number + // meaningless. + $this->assertSame($before, ContentVersion::current()); + } + + #[Test] + public function taking_something_down_moves_it_too(): void + { + $page = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->subDay(), + ]); + + $before = ContentVersion::current(); + + $page->update(['status' => PageStatus::Draft]); + + // Going away is exactly as visible to a reader as arriving. + $this->assertGreaterThan($before, ContentVersion::current()); + } + + #[Test] + public function deleting_published_work_moves_it(): void + { + $project = Project::factory()->published()->create(); + + $before = ContentVersion::current(); + + $project->delete(); + + $this->assertGreaterThan($before, ContentVersion::current()); + } +} diff --git a/tests/Feature/Site/MaintenanceModeTest.php b/tests/Feature/Site/MaintenanceModeTest.php new file mode 100644 index 0000000..f18d110 --- /dev/null +++ b/tests/Feature/Site/MaintenanceModeTest.php @@ -0,0 +1,207 @@ +withHeaders(['Accept-Language' => 'es']); + } + + #[Test] + public function the_public_api_answers_503_with_the_notice(): void + { + Project::factory()->published()->create(); + + Maintenance::enable(message: ['es' => 'Volvemos en una hora.']); + + $response = $this->getJson('/api/public/v1/projects'); + + $response->assertStatus(503) + ->assertJsonPath('maintenance', true) + ->assertJsonPath('message', 'Volvemos en una hora.'); + + $this->assertNotNull($response->headers->get('Retry-After')); + + // A cached 503 would outlive the maintenance itself. + $this->assertSame('no-store, private', $response->headers->get('Cache-Control')); + } + + #[Test] + public function the_notice_is_returned_in_the_requested_language(): void + { + Maintenance::enable(message: [ + 'es' => 'Estamos de obras.', + 'ca' => 'Estem d\'obres.', + ]); + + $this->getJson('/api/public/v1/site?lang=ca') + ->assertStatus(503) + ->assertJsonPath('message', 'Estem d\'obres.'); + } + + #[Test] + public function a_language_without_a_written_notice_falls_back(): void + { + Maintenance::enable(message: ['es' => 'Solo en castellano.']); + + // Falls back to the default locale's wording rather than showing + // nothing to an English visitor. + $this->getJson('/api/public/v1/site?lang=en') + ->assertStatus(503) + ->assertJsonPath('message', 'Solo en castellano.'); + } + + #[Test] + public function with_no_notice_written_a_translated_default_is_used(): void + { + Maintenance::enable(); + + $this->getJson('/api/public/v1/site?lang=en') + ->assertStatus(503) + ->assertJsonPath('message', __('maintenance.default_message', [], 'en')); + } + + #[Test] + public function the_panel_keeps_working_while_the_site_is_down(): void + { + $this->seed(RolePermissionSeeder::class); + + Maintenance::enable(); + + // The whole point: the collective must be able to fix whatever caused + // the maintenance. + $this->actingAs($this->userWithRoles('admin')) + ->get('/admin') + ->assertSuccessful(); + } + + #[Test] + public function the_admin_api_keeps_working_while_the_site_is_down(): void + { + $this->seed(RolePermissionSeeder::class); + + Maintenance::enable(); + + $user = $this->userWithRoles('editor'); + + $this->getJson('/api/v1/me', $this->apiHeadersFor($user))->assertOk(); + } + + #[Test] + public function the_preview_secret_lets_the_team_see_the_real_site(): void + { + config()->set('site.preview_token', 'the-secret'); + + Project::factory()->published()->create(); + + Maintenance::enable(); + + $this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret']) + ->assertOk() + ->assertJsonCount(1, 'data'); + } + + #[Test] + public function a_trusted_ip_also_bypasses_it(): void + { + config()->set('site.trusted_ips', ['127.0.0.1']); + + Project::factory()->published()->create(); + + Maintenance::enable(); + + $this->getJson('/api/public/v1/projects')->assertOk(); + } + + #[Test] + public function it_lifts_itself_once_the_window_has_passed(): void + { + Maintenance::enable(until: now()->addHour()->toImmutable()); + $this->assertTrue(Maintenance::isEnabled()); + + $this->travel(2)->hours(); + + // A forgotten flag must not keep the site down forever. + $this->assertFalse(Maintenance::isEnabled()); + $this->getJson('/api/public/v1/site')->assertOk(); + } + + #[Test] + public function retry_after_reflects_the_remaining_window(): void + { + Maintenance::enable(until: now()->addMinutes(10)->toImmutable()); + + $retryAfter = (int) $this->getJson('/api/public/v1/site') + ->assertStatus(503) + ->headers->get('Retry-After'); + + $this->assertGreaterThan(500, $retryAfter); + $this->assertLessThanOrEqual(600, $retryAfter); + } + + #[Test] + public function disabling_brings_the_site_back(): void + { + Maintenance::enable(); + $this->getJson('/api/public/v1/site')->assertStatus(503); + + Maintenance::disable(); + $this->getJson('/api/public/v1/site')->assertOk(); + } + + #[Test] + public function the_setting_is_cached_but_a_change_takes_effect_at_once(): void + { + $this->getJson('/api/public/v1/site')->assertOk(); + + Maintenance::enable(); + + // Writes clear the cache entry, so there is no stale window in which + // the site stays up after someone flipped the switch. + $this->getJson('/api/public/v1/site')->assertStatus(503); + } + + #[Test] + public function toggling_it_needs_its_own_permission(): void + { + $this->seed(RolePermissionSeeder::class); + + $editor = $this->userWithRoles('editor'); + $admin = $this->userWithRoles('admin'); + + // Editors can see the state but not take the site down. + $this->assertTrue($editor->can('settings.view')); + $this->assertFalse($editor->can('settings.maintenance')); + $this->assertTrue($admin->can('settings.maintenance')); + } + + #[Test] + public function the_command_reports_and_toggles(): void + { + $this->artisan('site:maintenance', ['--message' => 'Obras']) + ->assertSuccessful(); + + $this->assertTrue(Maintenance::isEnabled()); + $this->assertSame('Obras', Maintenance::message('es')); + + $this->artisan('site:maintenance', ['--up' => true])->assertSuccessful(); + + $this->assertFalse(Maintenance::isEnabled()); + } +} diff --git a/tests/Feature/Site/PruneAndRedirectHealthTest.php b/tests/Feature/Site/PruneAndRedirectHealthTest.php new file mode 100644 index 0000000..7e7b71e --- /dev/null +++ b/tests/Feature/Site/PruneAndRedirectHealthTest.php @@ -0,0 +1,193 @@ +runAll()->firstWhere('key', $key) + ?? throw new \RuntimeException("No finding for {$key}"); + } + + #[Test] + public function it_finds_a_redirect_that_chains_into_another(): void + { + Redirect::factory()->create(['from' => '/a', 'to' => '/b', 'locale' => 'es']); + Redirect::factory()->create(['from' => '/b', 'to' => '/c', 'locale' => 'es']); + + $finding = $this->finding('broken-redirects'); + + // The visitor is sent somewhere that sends them somewhere else: a + // round trip per hop, and eventually a loop. + $this->assertSame(Severity::Error, $finding->severity); + $this->assertSame(1, $finding->count); + } + + #[Test] + public function it_finds_a_redirect_that_lands_on_nothing(): void + { + Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/no-existe']); + + // Worse than the 404 it was meant to prevent: the visitor is bounced + // and lands on the same nothing. + $this->assertSame(1, $this->finding('redirects-to-nowhere')->count); + } + + #[Test] + public function a_redirect_to_a_real_page_is_fine(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Vivo'], 'slug' => ['es' => 'vivo'], + ]); + + Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/vivo']); + + $this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity); + } + + #[Test] + public function a_redirect_off_this_site_is_not_ours_to_verify(): void + { + Redirect::factory()->create(['from' => '/viejo', 'to' => 'https://otra-web.example/algo']); + + $this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity); + } + + #[Test] + public function pruning_keeps_the_first_and_last_of_a_history(): void + { + $page = Page::factory()->create(); + + foreach (range(1, 4) as $n) { + $page->update(['title' => ['es' => "Versión {$n}"]]); + } + + Revision::query()->update(['created_at' => now()->subYears(2)]); + + $this->artisan('content:prune')->assertSuccessful(); + + // Two facts survive whatever happens: when it appeared, and who + // touched it last. + $this->assertSame(2, $page->revisions()->count()); + // `reorder`, because the relation sorts newest-first by default and + // an added orderBy would only be a tie-breaker behind it. + $this->assertSame('created', $page->revisions()->reorder('id')->first()->event); + } + + #[Test] + public function recent_history_is_left_alone(): void + { + $page = Page::factory()->create(); + $page->update(['title' => ['es' => 'Reciente']]); + + $this->artisan('content:prune')->assertSuccessful(); + + $this->assertSame(2, $page->revisions()->count()); + } + + #[Test] + public function it_erases_what_has_been_in_the_bin_long_enough(): void + { + $old = Page::factory()->create(); + $old->delete(); + $old->forceFill(['deleted_at' => now()->subYear()])->save(); + + $recent = Page::factory()->create(); + $recent->delete(); + + $this->artisan('content:prune')->assertSuccessful(); + + $this->assertNull(Page::withTrashed()->find($old->id)); + $this->assertNotNull(Page::withTrashed()->find($recent->id)); + } + + #[Test] + public function a_dry_run_changes_nothing(): void + { + $page = Page::factory()->create(); + $page->delete(); + $page->forceFill(['deleted_at' => now()->subYear()])->save(); + + $this->artisan('content:prune', ['--dry-run' => true])->assertSuccessful(); + + $this->assertNotNull(Page::withTrashed()->find($page->id)); + } + + #[Test] + public function it_deletes_image_files_nothing_points_at(): void + { + $disk = Storage::disk(config('media.disk')); + + $disk->put('projects/en-uso.jpg', 'x'); + $disk->put('projects/huerfana.jpg', 'x'); + + Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']); + Media::factory()->create(['path' => 'projects/huerfana.jpg']); + + $this->artisan('content:prune', ['--media' => true])->assertSuccessful(); + + $this->assertTrue($disk->exists('projects/en-uso.jpg')); + $this->assertFalse($disk->exists('projects/huerfana.jpg')); + } + + #[Test] + public function an_image_belonging_to_something_in_the_bin_is_kept(): void + { + $disk = Storage::disk(config('media.disk')); + $disk->put('projects/borrado.jpg', 'x'); + + $project = Project::factory()->create(['cover_image' => 'projects/borrado.jpg']); + $project->delete(); + + $this->artisan('content:prune', ['--media' => true])->assertSuccessful(); + + // It can still be restored, and restoring it to a missing image would + // be worse than keeping a file. + $this->assertTrue($disk->exists('projects/borrado.jpg')); + } + + #[Test] + public function files_are_left_alone_unless_asked_for(): void + { + $disk = Storage::disk(config('media.disk')); + $disk->put('projects/huerfana.jpg', 'x'); + Media::factory()->create(['path' => 'projects/huerfana.jpg']); + + $this->artisan('content:prune')->assertSuccessful(); + + // Deleting a file is the one thing here that restoring a row cannot + // undo, so it never happens by routine. + $this->assertTrue($disk->exists('projects/huerfana.jpg')); + } +} diff --git a/tests/Feature/Site/PublicationWindowTest.php b/tests/Feature/Site/PublicationWindowTest.php new file mode 100644 index 0000000..2754b7f --- /dev/null +++ b/tests/Feature/Site/PublicationWindowTest.php @@ -0,0 +1,225 @@ +withHeaders(['Accept-Language' => 'es']); + Queue::fake(); + } + + #[Test] + public function a_page_stays_off_the_site_until_its_date(): void + { + Page::factory()->create([ + 'status' => PageStatus::Published, + 'title' => ['es' => 'El anuncio'], + 'slug' => ['es' => 'el-anuncio'], + 'published_at' => now()->addDay(), + ]); + + $this->getJson('/api/public/v1/pages/el-anuncio')->assertNotFound(); + + $this->travel(2)->days(); + + $this->getJson('/api/public/v1/pages/el-anuncio')->assertOk(); + } + + #[Test] + public function a_page_drops_off_the_site_on_its_end_date(): void + { + Page::factory()->create([ + 'status' => PageStatus::Published, + 'title' => ['es' => 'Taller de octubre'], + 'slug' => ['es' => 'taller'], + 'published_at' => now()->subWeek(), + 'unpublish_at' => now()->addDay(), + ]); + + $this->getJson('/api/public/v1/pages/taller')->assertOk(); + + $this->travel(2)->days(); + + // Nobody has to remember to take it down, which is the whole point: + // nobody ever does. + $this->getJson('/api/public/v1/pages/taller')->assertNotFound(); + } + + #[Test] + public function the_window_holds_without_anything_running(): void + { + Page::factory()->create([ + 'status' => PageStatus::Published, + 'title' => ['es' => 'Caducada'], + 'slug' => ['es' => 'caducada'], + 'published_at' => now()->subMonth(), + 'unpublish_at' => now()->subDay(), + ]); + + // No command, no queue, no scheduler — the scope is what decides. + $this->getJson('/api/public/v1/pages') + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + #[Test] + public function an_expired_project_leaves_the_url_list_too(): void + { + Project::factory()->published()->create([ + 'title' => ['es' => 'Terminado'], + 'slug' => ['es' => 'terminado'], + 'unpublish_at' => now()->subDay(), + ]); + + $slugs = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data'); + + // Otherwise the frontend would keep it in the sitemap and point search + // engines at a 404. + $this->assertEmpty($slugs['projects'] ?? []); + } + + #[Test] + public function preview_still_reaches_content_outside_its_window(): void + { + config()->set('site.preview_token', 'secreto'); + + Page::factory()->create([ + 'status' => PageStatus::Published, + 'title' => ['es' => 'Aún no'], + 'slug' => ['es' => 'aun-no'], + 'published_at' => now()->addWeek(), + ]); + + // Checking a scheduled page before its date is exactly what preview is + // for. + $this->withHeaders(['X-Preview-Token' => 'secreto']) + ->getJson('/api/public/v1/pages/aun-no') + ->assertOk(); + } + + #[Test] + public function the_command_closes_a_call_whose_date_has_passed(): void + { + $call = Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => now()->subWeek(), + ]); + + $this->artisan('content:schedule')->assertSuccessful(); + + // The site already stopped offering it; this is about the panel not + // claiming otherwise, and a counter that stays worth reading. + $this->assertSame(CallStatus::Closed, $call->refresh()->status); + } + + #[Test] + public function the_command_leaves_a_call_that_is_still_open(): void + { + $open = Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => now()->addWeek(), + ]); + + $undated = Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => null, + ]); + + $this->artisan('content:schedule')->assertSuccessful(); + + $this->assertSame(CallStatus::Open, $open->refresh()->status); + $this->assertSame(CallStatus::Open, $undated->refresh()->status); + } + + #[Test] + public function a_dry_run_changes_nothing(): void + { + $call = Call::factory()->create([ + 'project_id' => null, + 'status' => CallStatus::Open, + 'closes_at' => now()->subWeek(), + ]); + + $this->artisan('content:schedule', ['--dry-run' => true])->assertSuccessful(); + + $this->assertSame(CallStatus::Open, $call->refresh()->status); + } + + #[Test] + public function the_panel_can_tell_the_three_states_apart(): void + { + $scheduled = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->addDays(3), + ]); + + $expired = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->subMonth(), + 'unpublish_at' => now()->subDay(), + ]); + + $live = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->subDay(), + ]); + + // A panel that reports "Published" for something the site is not + // showing is lying to whoever wrote it. + $this->assertTrue($scheduled->isScheduled()); + $this->assertFalse($scheduled->hasExpired()); + + $this->assertTrue($expired->hasExpired()); + $this->assertFalse($expired->isScheduled()); + + $this->assertFalse($live->isScheduled()); + $this->assertFalse($live->hasExpired()); + } + + #[Test] + public function content_about_to_go_can_be_spotted_in_advance(): void + { + $soon = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->subMonth(), + 'unpublish_at' => now()->addDays(2), + ]); + + $later = Page::factory()->create([ + 'status' => PageStatus::Published, + 'published_at' => now()->subMonth(), + 'unpublish_at' => now()->addMonths(2), + ]); + + $this->assertTrue($soon->expiresSoon()); + $this->assertFalse($later->expiresSoon()); + } +} diff --git a/tests/Feature/Site/RedirectsTest.php b/tests/Feature/Site/RedirectsTest.php new file mode 100644 index 0000000..b0f2b16 --- /dev/null +++ b/tests/Feature/Site/RedirectsTest.php @@ -0,0 +1,168 @@ +withHeaders(['Accept-Language' => 'es']); + Queue::fake(); + } + + #[Test] + public function renaming_a_slug_leaves_a_redirect_behind(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Mi proyecto'], + 'slug' => ['es' => 'nombre-viejo'], + ]); + + $project->update(['slug' => ['es' => 'nombre-nuevo']]); + + $redirect = Redirect::query()->sole(); + + $this->assertSame('/proyectos/nombre-viejo', $redirect->from); + $this->assertSame('/proyectos/nombre-nuevo', $redirect->to); + $this->assertSame(301, $redirect->status); + $this->assertSame('es', $redirect->locale); + $this->assertTrue($redirect->wasAutomatic()); + } + + #[Test] + public function each_language_gets_its_own_rule(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Uno', 'ca' => 'U'], + 'slug' => ['es' => 'viejo-es', 'ca' => 'vell-ca'], + ]); + + $project->update(['slug' => ['es' => 'nuevo-es', 'ca' => 'nou-ca']]); + + // The Catalan URL carries its own prefix and its own path segment, so + // one rule could never cover both. + $this->assertSame( + ['/proyectos/viejo-es' => '/proyectos/nuevo-es', '/ca/projectes/vell-ca' => '/ca/projectes/nou-ca'], + Redirect::query()->pluck('to', 'from')->all(), + ); + } + + #[Test] + public function adding_a_translation_is_not_a_rename(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Uno'], + 'slug' => ['es' => 'uno'], + ]); + + $project->update(['slug' => ['es' => 'uno', 'ca' => 'u']]); + + // Nothing broke: there was no Catalan address before. + $this->assertSame(0, Redirect::query()->count()); + } + + #[Test] + public function a_second_rename_does_not_leave_a_chain_behind(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Uno'], 'slug' => ['es' => 'v1'], + ]); + + $project->update(['slug' => ['es' => 'v2']]); + $project->update(['slug' => ['es' => 'v3']]); + + // Both old addresses point at the current one. A frontend following a + // chain would cost an extra round trip per hop. + $this->assertSame( + ['/proyectos/v1' => '/proyectos/v3', '/proyectos/v2' => '/proyectos/v3'], + Redirect::query()->orderBy('from')->pluck('to', 'from')->all(), + ); + } + + #[Test] + public function renaming_back_does_not_create_a_loop(): void + { + $project = Project::factory()->published()->create([ + 'title' => ['es' => 'Uno'], 'slug' => ['es' => 'original'], + ]); + + $project->update(['slug' => ['es' => 'cambiado']]); + $project->update(['slug' => ['es' => 'original']]); + + // A rule from a path to itself would send a browser round for ever. + foreach (Redirect::query()->get() as $redirect) { + $this->assertFalse($redirect->isCircular(), "{$redirect->from} → {$redirect->to}"); + } + } + + #[Test] + public function it_works_for_every_kind_of_record_with_a_public_url(): void + { + $service = Service::factory()->create([ + 'title' => ['es' => 'Servicio'], 'slug' => ['es' => 'antiguo'], + ]); + + $service->update(['slug' => ['es' => 'moderno']]); + + $this->assertSame('/servicios/antiguo', Redirect::query()->sole()->from); + } + + #[Test] + public function the_frontend_gets_the_whole_list_in_one_request(): void + { + Redirect::factory()->create(['from' => '/vieja', 'to' => '/nueva', 'locale' => null]); + Redirect::factory()->disabled()->create(['from' => '/apagada', 'to' => '/otra']); + + $data = $this->getJson('/api/public/v1/redirects')->assertOk()->json('data'); + + // One request rather than a lookup per 404: there are never many, and + // an SSR frontend would otherwise ask on every miss it serves. + $this->assertCount(1, $data); + $this->assertSame('/vieja', $data[0]['from']); + $this->assertNull($data[0]['locale']); + } + + #[Test] + public function paths_are_normalised_however_they_were_typed(): void + { + $this->assertSame('/proyectos/uno', Redirect::normalise('proyectos/uno')); + $this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno/')); + $this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno?utm=x')); + + // So a rule typed by hand matches one written automatically. + $this->assertSame('/', Redirect::normalise('/')); + } + + #[Test] + public function following_one_can_be_counted(): void + { + $redirect = Redirect::factory()->create(['hits' => 0]); + + $redirect->markUsed(); + + // The number that decides whether a rule can ever be deleted. + $this->assertSame(1, $redirect->fresh()->hits); + $this->assertNotNull($redirect->fresh()->last_used_at); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ + $permissions) { + $unknown = array_diff($permissions, $catalogue); + + $this->assertSame( + [], + array_values($unknown), + "Role [{$role}] references permissions that do not exist: ".implode(', ', $unknown) + ); + } + } + + #[Test] + public function the_grouped_view_covers_the_whole_catalogue(): void + { + $flattened = array_merge(...array_values(Permissions::grouped())); + + $this->assertEqualsCanonicalizing(Permissions::all(), $flattened); + } + + #[Test] + public function super_admin_holds_no_explicit_permissions(): void + { + // Its power comes from the Gate::before hook, so the definition stays + // empty and can never fall behind the catalogue. + $this->assertSame([], Permissions::roleDefinitions()['super_admin']); + } + + #[Test] + public function every_role_that_can_use_the_panel_can_reach_it(): void + { + foreach (Permissions::roleDefinitions() as $role => $permissions) { + if ($role === 'super_admin' || $permissions === []) { + continue; + } + + $this->assertContains( + Permissions::PANEL_ACCESS, + $permissions, + "Role [{$role}] has permissions but cannot open the panel." + ); + } + } + + #[Test] + public function the_default_role_for_new_logins_exists(): void + { + $this->assertArrayHasKey( + (string) config('pireon.default_role'), + Permissions::roleDefinitions(), + ); + } + + #[Test] + public function a_translatable_value_can_be_submitted_as_a_plain_string(): void + { + app()->setLocale('ca'); + + $this->assertSame(['ca' => 'Hola'], TranslatablePayload::normalise('Hola')); + } + + #[Test] + public function unsupported_locales_are_stripped_from_incoming_payloads(): void + { + $this->assertSame( + ['es' => 'Hola', 'en' => 'Hi'], + TranslatablePayload::normalise(['es' => 'Hola', 'en' => 'Hi', 'de' => 'Hallo']), + ); + } +} diff --git a/tests/Unit/TranslationCatalogueTest.php b/tests/Unit/TranslationCatalogueTest.php new file mode 100644 index 0000000..a742596 --- /dev/null +++ b/tests/Unit/TranslationCatalogueTest.php @@ -0,0 +1,186 @@ + + */ + protected function locales(): array + { + return array_keys((array) config('locales.supported')); + } + + /** + * Every leaf of a translation file, as dotted keys. + * + * @param array $translations + * @return array + */ + protected function flatten(array $translations, string $prefix = ''): array + { + $keys = []; + + foreach ($translations as $key => $value) { + $path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; + + if (is_array($value)) { + $keys = [...$keys, ...$this->flatten($value, $path)]; + + continue; + } + + $keys[] = $path; + } + + return $keys; + } + + /** + * @return array>> + */ + protected function catalogue(): array + { + $catalogue = []; + + foreach ($this->locales() as $locale) { + foreach (glob(lang_path("{$locale}/*.php")) ?: [] as $file) { + $catalogue[basename($file)][$locale] = $this->flatten(require $file); + } + } + + return $catalogue; + } + + #[Test] + public function every_language_carries_the_same_keys(): void + { + $missing = []; + + foreach ($this->catalogue() as $file => $byLocale) { + $everyKey = array_unique(array_merge(...array_values($byLocale))); + + foreach ($this->locales() as $locale) { + foreach (array_diff($everyKey, $byLocale[$locale] ?? []) as $key) { + $missing[] = "[{$locale}] {$file} :: {$key}"; + } + } + } + + $this->assertSame([], $missing, "Untranslated keys:\n".implode("\n", $missing)); + } + + #[Test] + public function no_translation_is_left_empty(): void + { + $empty = []; + + foreach ($this->locales() as $locale) { + foreach (glob(lang_path("{$locale}/*.php")) ?: [] as $file) { + foreach (require $file as $key => $value) { + foreach ($this->leaves($value, (string) $key) as $path => $text) { + if (trim((string) $text) === '') { + $empty[] = "[{$locale}] ".basename($file)." :: {$path}"; + } + } + } + } + } + + $this->assertSame([], $empty, "Empty translations:\n".implode("\n", $empty)); + } + + /** + * Every key the application actually asks for exists. + * + * The mirror image of the test above: that one catches a key translated in + * one language and forgotten in another, this one catches a key nobody + * translated at all. + */ + #[Test] + public function every_key_the_code_uses_has_a_translation(): void + { + $default = (string) config('locales.default'); + $missing = []; + + foreach ($this->sourceFiles() as $file) { + $source = (string) file_get_contents($file->getPathname()); + + preg_match_all( + '/(?:__|trans_choice)\(\s*[\'"]([a-z0-9_]+\.[a-z0-9_.\-]+)[\'"]/i', + $source, + $matches, + ); + + foreach ($matches[1] as $key) { + [$group] = explode('.', $key, 2); + + // Keys belonging to a package (`filament-panels::…`) or built + // dynamically are not this test's business. + if (! file_exists(lang_path("{$default}/{$group}.php"))) { + continue; + } + + if (! is_string(__($key)) || __($key) === $key) { + $missing[] = $key.' ('.str_replace(base_path().'/', '', $file->getPathname()).')'; + } + } + } + + $this->assertSame([], array_unique($missing), "Keys with no translation:\n".implode("\n", array_unique($missing))); + } + + /** + * @return array + */ + protected function sourceFiles(): array + { + $files = []; + + foreach ([app_path(), resource_path('views')] as $directory) { + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)) as $file) { + if ($file->isFile() && in_array($file->getExtension(), ['php'], true)) { + $files[] = $file; + } + } + } + + return $files; + } + + /** + * @return array + */ + protected function leaves(mixed $value, string $prefix): array + { + if (! is_array($value)) { + return [$prefix => $value]; + } + + $leaves = []; + + foreach ($value as $key => $child) { + $leaves += $this->leaves($child, "{$prefix}.{$key}"); + } + + return $leaves; + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..1fd66d5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + fonts: [ + bunny('Instrument Sans', { + weights: [400, 500, 600], + }), + ], + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});