From 3897113313f36602d0542236870bd48050477768 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Wed, 19 Aug 2026 17:55:27 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Pireon=20Admin=20=E2=80=94?= =?UTF-8?q?=20headless=20admin=20panel=20and=20public=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 22 + .editorconfig | 18 + .env.example | 188 + .env.production.example | 118 + .gitattributes | 11 + .gitignore | 29 + .npmrc | 2 + Dockerfile | 122 + README.md | 1572 ++ app/Actions/Auth/SyncPireonUser.php | 81 + app/Auth/PireonTokenGuard.php | 95 + app/Console/Commands/AssignRole.php | 67 + .../Commands/GenerateApiTypesCommand.php | 298 + app/Console/Commands/ProcessMediaCommand.php | 70 + app/Console/Commands/PruneContentCommand.php | 228 + .../Commands/ScheduleContentCommand.php | 82 + app/Console/Commands/SiteCheckCommand.php | 101 + .../Commands/SiteMaintenanceCommand.php | 76 + app/Console/Commands/SyncPermissions.php | 22 + app/Console/Commands/SyncRepositories.php | 98 + app/Enums/ApplicationKind.php | 46 + app/Enums/ApplicationStatus.php | 57 + app/Enums/CallStatus.php | 37 + app/Enums/GitProvider.php | 60 + app/Enums/MissingUrlStatus.php | 45 + app/Enums/PageStatus.php | 52 + app/Enums/ProjectStatus.php | 53 + app/Enums/RadarRing.php | 42 + app/Enums/ReviewEvent.php | 58 + app/Enums/TechnologyCategory.php | 30 + .../Concerns/WarnsOfConcurrentEditing.php | 154 + app/Filament/Pages/Auth/PireonLogin.php | 178 + app/Filament/Pages/SiteHealth.php | 147 + app/Filament/Pages/SiteMaintenance.php | 216 + app/Filament/Pages/Trash.php | 183 + .../ReviewsRelationManager.php | 98 + .../RevisionsRelationManager.php | 127 + .../Applications/ApplicationResource.php | 113 + .../Applications/Pages/ListApplications.php | 54 + .../Applications/Pages/ViewApplication.php | 68 + .../Schemas/ApplicationInfolist.php | 108 + .../Applications/Tables/ApplicationsTable.php | 163 + app/Filament/Resources/Calls/CallResource.php | 291 + .../Resources/Calls/Pages/CreateCall.php | 13 + .../Resources/Calls/Pages/EditCall.php | 24 + .../Resources/Calls/Pages/ListCalls.php | 21 + .../ApplicationsRelationManager.php | 93 + .../Resources/Categories/CategoryResource.php | 94 + .../Categories/Pages/CreateCategory.php | 11 + .../Categories/Pages/EditCategory.php | 26 + .../Categories/Pages/ListCategories.php | 19 + .../Categories/Schemas/CategoryForm.php | 68 + .../Categories/Tables/CategoriesTable.php | 80 + .../Resources/Clients/ClientResource.php | 220 + .../Resources/Clients/Pages/CreateClient.php | 13 + .../Resources/Clients/Pages/EditClient.php | 24 + .../Resources/Clients/Pages/ListClients.php | 21 + .../Resources/Media/MediaResource.php | 203 + .../Resources/Media/Pages/ListMedia.php | 50 + .../Resources/Members/MemberResource.php | 93 + .../Resources/Members/Pages/CreateMember.php | 13 + .../Resources/Members/Pages/EditMember.php | 24 + .../Resources/Members/Pages/ListMembers.php | 21 + .../Resources/Members/Schemas/MemberForm.php | 121 + .../Resources/Members/Tables/MembersTable.php | 102 + .../MissingUrls/MissingUrlResource.php | 287 + .../MissingUrls/Pages/ListMissingUrls.php | 19 + app/Filament/Resources/Pages/PageResource.php | 103 + .../Resources/Pages/Pages/CreatePage.php | 32 + .../Resources/Pages/Pages/EditPage.php | 42 + .../Resources/Pages/Pages/ListPages.php | 21 + .../Resources/Pages/Schemas/PageForm.php | 176 + .../Resources/Pages/Tables/PagesTable.php | 270 + .../Projects/Pages/CreateProject.php | 13 + .../Resources/Projects/Pages/EditProject.php | 24 + .../Resources/Projects/Pages/ListProjects.php | 21 + .../Resources/Projects/ProjectResource.php | 113 + .../GalleryRelationManager.php | 142 + .../RepositoriesRelationManager.php | 136 + .../Projects/Schemas/ProjectForm.php | 226 + .../Projects/Tables/ProjectsTable.php | 254 + .../Redirects/Pages/CreateRedirect.php | 13 + .../Redirects/Pages/EditRedirect.php | 24 + .../Redirects/Pages/ListRedirects.php | 21 + .../Resources/Redirects/RedirectResource.php | 210 + .../Repositories/Pages/CreateRepository.php | 13 + .../Repositories/Pages/EditRepository.php | 24 + .../Repositories/Pages/ListRepositories.php | 21 + .../Repositories/RepositoryResource.php | 135 + .../Repositories/Tables/RepositoriesTable.php | 178 + .../Resources/RepositoryIssuesResource.php | 75 + .../Pages/EditRepositoryIssue.php | 21 + .../Pages/ListRepositoryIssues.php | 18 + .../Schemas/RepositoryIssueForm.php | 88 + .../Tables/RepositoryIssuesTable.php | 95 + .../Resources/Roles/Pages/CreateRole.php | 15 + .../Resources/Roles/Pages/EditRole.php | 24 + .../Roles/Pages/HandlesRolePermissions.php | 100 + .../Resources/Roles/Pages/ListRoles.php | 21 + app/Filament/Resources/Roles/RoleResource.php | 69 + .../Resources/Roles/Schemas/RoleForm.php | 85 + .../Resources/Roles/Tables/RolesTable.php | 53 + .../Services/Pages/CreateService.php | 13 + .../Resources/Services/Pages/EditService.php | 24 + .../Resources/Services/Pages/ListServices.php | 21 + .../Resources/Services/ServiceResource.php | 408 + app/Filament/Resources/SettingsResource.php | 69 + .../SettingsResource/Pages/EditSettings.php | 23 + .../SettingsResource/Pages/ListSettings.php | 20 + .../SettingsResource/Schemas/SettingsForm.php | 56 + .../SettingsResource/Tables/SettingsTable.php | 35 + .../Resources/Talks/Pages/CreateTalk.php | 13 + .../Resources/Talks/Pages/EditTalk.php | 24 + .../Resources/Talks/Pages/ListTalks.php | 21 + app/Filament/Resources/Talks/TalkResource.php | 220 + .../Technologies/Pages/CreateTechnology.php | 13 + .../Technologies/Pages/EditTechnology.php | 24 + .../Technologies/Pages/ListTechnologies.php | 21 + .../Technologies/TechnologyResource.php | 234 + .../Testimonials/Pages/CreateTestimonial.php | 13 + .../Testimonials/Pages/EditTestimonial.php | 24 + .../Testimonials/Pages/ListTestimonials.php | 21 + .../Testimonials/TestimonialResource.php | 252 + .../Resources/Users/Pages/EditUser.php | 16 + .../Resources/Users/Pages/ListUsers.php | 13 + .../SessionsRelationManager.php | 138 + .../Resources/Users/Schemas/UserForm.php | 95 + .../Resources/Users/Tables/UsersTable.php | 87 + app/Filament/Resources/Users/UserResource.php | 95 + app/Filament/Support/PreviewAction.php | 45 + app/Filament/Support/ReviewActions.php | 181 + app/Filament/Support/ReviewStatusColumn.php | 49 + app/Filament/Support/TranslatableTabs.php | 58 + .../Support/TranslationStatusColumn.php | 90 + app/Filament/Support/ViewsColumn.php | 46 + app/Filament/Widgets/AdvancedAnalytics.php | 106 + app/Filament/Widgets/ContentOverview.php | 60 + app/Filament/Widgets/GrowthChart.php | 103 + app/Filament/Widgets/LanguagesChart.php | 75 + app/Filament/Widgets/MostRead.php | 88 + app/Filament/Widgets/WhatNeedsYou.php | 106 + .../PublicApi/V1/ApplicationController.php | 202 + .../Api/PublicApi/V1/CallController.php | 50 + .../Api/PublicApi/V1/MemberController.php | 52 + .../Api/PublicApi/V1/PageController.php | 41 + .../Api/PublicApi/V1/ProjectController.php | 86 + .../Api/PublicApi/V1/PublicController.php | 36 + .../Api/PublicApi/V1/ShowcaseController.php | 124 + .../Api/PublicApi/V1/SiteController.php | 230 + .../Api/PublicApi/V1/TelemetryController.php | 142 + .../Controllers/Api/V1/CategoryController.php | 74 + app/Http/Controllers/Api/V1/MeController.php | 26 + .../Controllers/Api/V1/PageController.php | 138 + .../Controllers/Api/V1/RoleController.php | 50 + .../Controllers/Api/V1/TokenController.php | 97 + .../Controllers/Api/V1/UserController.php | 83 + .../Controllers/Auth/PireonAuthController.php | 132 + app/Http/Controllers/Controller.php | 14 + app/Http/Controllers/DocsController.php | 46 + app/Http/Controllers/LocaleController.php | 28 + app/Http/Middleware/AllowPreview.php | 81 + app/Http/Middleware/CachePublicResponse.php | 89 + app/Http/Middleware/EnsureSiteIsAvailable.php | 61 + app/Http/Middleware/SetLocale.php | 93 + app/Http/Middleware/VerifyIngestToken.php | 47 + app/Http/Requests/Api/V1/CategoryRequest.php | 42 + app/Http/Requests/Api/V1/PageRequest.php | 73 + .../PublicApi/ReportMissingUrlRequest.php | 73 + .../Requests/PublicApi/ReportViewsRequest.php | 74 + .../PublicApi/SubmitApplicationRequest.php | 115 + app/Http/Resources/CategoryResource.php | 43 + app/Http/Resources/PageResource.php | 61 + .../PublicApi/PublicCallResource.php | 53 + .../PublicApi/PublicCategoryResource.php | 30 + .../PublicApi/PublicClientResource.php | 43 + .../PublicApi/PublicIssueResource.php | 50 + .../PublicApi/PublicMemberResource.php | 78 + .../PublicApi/PublicPageResource.php | 67 + .../PublicApi/PublicProjectResource.php | 141 + .../PublicApi/PublicRepositoryResource.php | 49 + .../PublicApi/PublicServiceResource.php | 62 + .../PublicApi/PublicTalkResource.php | 43 + .../PublicApi/PublicTechnologyResource.php | 50 + .../PublicApi/PublicTestimonialResource.php | 52 + app/Http/Resources/RoleResource.php | 33 + app/Http/Resources/UserResource.php | 46 + app/Jobs/ProcessMedia.php | 46 + app/Jobs/SyncRepository.php | 63 + app/Models/Application.php | 131 + app/Models/Call.php | 131 + app/Models/Category.php | 59 + app/Models/Client.php | 90 + app/Models/Concerns/BumpsContentVersion.php | 29 + app/Models/Concerns/HasContentViews.php | 50 + app/Models/Concerns/HasImages.php | 54 + app/Models/Concerns/HasPublicationWindow.php | 63 + app/Models/Concerns/HasReviews.php | 252 + app/Models/Concerns/HasRevisions.php | 145 + app/Models/Concerns/RecordsSlugChanges.php | 93 + app/Models/ContentView.php | 77 + app/Models/ImageAttachment.php | 72 + app/Models/Media.php | 244 + app/Models/Member.php | 144 + app/Models/MissingUrl.php | 154 + app/Models/Page.php | 197 + app/Models/Project.php | 264 + app/Models/Redirect.php | 112 + app/Models/Repository.php | 136 + app/Models/RepositoryIssue.php | 61 + app/Models/ReviewComment.php | 57 + app/Models/Revision.php | 91 + app/Models/Service.php | 129 + app/Models/Setting.php | 68 + app/Models/Talk.php | 84 + app/Models/Technology.php | 110 + app/Models/Testimonial.php | 106 + app/Models/User.php | 123 + app/Models/UserSession.php | 75 + app/Notifications/ApplicationReceived.php | 105 + app/Notifications/ReviewRequested.php | 91 + app/Notifications/ReviewReturned.php | 60 + app/Policies/ApplicationPolicy.php | 25 + app/Policies/CallPolicy.php | 13 + app/Policies/CategoryPolicy.php | 28 + app/Policies/ClientPolicy.php | 13 + app/Policies/MediaPolicy.php | 33 + app/Policies/MemberPolicy.php | 29 + app/Policies/MissingUrlPolicy.php | 41 + app/Policies/PagePolicy.php | 43 + app/Policies/ProjectPolicy.php | 25 + app/Policies/RedirectPolicy.php | 13 + app/Policies/RepositoryPolicy.php | 25 + app/Policies/ResourcePolicy.php | 72 + app/Policies/RolePolicy.php | 44 + app/Policies/ServicePolicy.php | 13 + app/Policies/TalkPolicy.php | 13 + app/Policies/TechnologyPolicy.php | 34 + app/Policies/TestimonialPolicy.php | 13 + app/Policies/UserPolicy.php | 74 + app/Providers/AppServiceProvider.php | 108 + app/Providers/AuthServiceProvider.php | 74 + app/Providers/Filament/AdminPanelProvider.php | 127 + app/Rules/Translatable.php | 76 + app/Rules/UniqueTranslatedSlug.php | 64 + .../Git/Contracts/RepositoryProvider.php | 35 + app/Services/Git/GitException.php | 64 + app/Services/Git/GitHubProvider.php | 248 + app/Services/Git/GitManager.php | 104 + app/Services/Git/GiteaProvider.php | 295 + app/Services/Git/IssueSnapshot.php | 62 + app/Services/Git/RepositorySnapshot.php | 71 + app/Services/Media/ImageProcessor.php | 318 + app/Services/Pireon/AccessToken.php | 86 + app/Services/Pireon/PireonClient.php | 252 + app/Services/Pireon/PireonException.php | 80 + app/Services/Pireon/PireonIdentity.php | 93 + app/Services/Pireon/TokenStore.php | 81 + app/Support/ContentTypes.php | 82 + app/Support/ContentVersion.php | 57 + app/Support/DuplicateTitles.php | 121 + app/Support/Health/Check.php | 25 + app/Support/Health/Checks/BrokenImages.php | 51 + app/Support/Health/Checks/BrokenRedirects.php | 71 + .../Health/Checks/ClientsWithoutLogo.php | 48 + app/Support/Health/Checks/DuplicateSlugs.php | 74 + app/Support/Health/Checks/ExpiredCalls.php | 50 + .../Health/Checks/ImagesWithoutAlt.php | 71 + app/Support/Health/Checks/IncompleteSeo.php | 69 + app/Support/Health/Checks/MissingSlugs.php | 79 + .../Health/Checks/MissingTranslations.php | 67 + .../Health/Checks/RedirectsToNowhere.php | 83 + .../Health/Checks/ServicesWithoutWork.php | 50 + .../Health/Checks/StaleRepositories.php | 43 + app/Support/Health/Checks/StaleReviews.php | 84 + .../Health/Checks/StaleTranslations.php | 81 + .../Health/Checks/StalledMediaQueue.php | 44 + .../Health/Checks/TalksWithoutSpeaker.php | 45 + .../Checks/TestimonialsMissingOriginal.php | 51 + .../Health/Checks/UnansweredSubmissions.php | 54 + .../Health/Checks/UnresolvedMissingUrls.php | 65 + app/Support/Health/Finding.php | 66 + app/Support/Health/HealthReport.php | 160 + app/Support/Health/Severity.php | 54 + app/Support/ImagePayload.php | 71 + app/Support/Maintenance.php | 138 + app/Support/Permissions.php | 242 + app/Support/Presence.php | 73 + app/Support/PreviewLink.php | 74 + app/Support/ReviewTarget.php | 82 + app/Support/SiteUrls.php | 76 + app/Support/TranslatablePayload.php | 124 + app/Support/TranslationFreshness.php | 180 + artisan | 18 + bootstrap/app.php | 58 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 11 + composer.json | 93 + composer.lock | 12598 ++++++++++++++++ config/app.php | 126 + config/auth.php | 127 + config/cache.php | 136 + config/database.php | 184 + config/filesystems.php | 80 + config/git.php | 113 + config/locales.php | 64 + config/logging.php | 140 + config/mail.php | 118 + config/media.php | 79 + config/permission.php | 206 + config/pireon.php | 101 + config/queue.php | 129 + config/services.php | 38 + config/session.php | 233 + config/site.php | 168 + database/.gitignore | 1 + database/factories/ApplicationFactory.php | 70 + database/factories/CallFactory.php | 61 + database/factories/CategoryFactory.php | 47 + database/factories/ClientFactory.php | 50 + database/factories/ImageAttachmentFactory.php | 38 + database/factories/MediaFactory.php | 57 + database/factories/MemberFactory.php | 69 + database/factories/MissingUrlFactory.php | 56 + database/factories/PageFactory.php | 91 + database/factories/ProjectFactory.php | 93 + database/factories/RedirectFactory.php | 36 + database/factories/RepositoryFactory.php | 76 + database/factories/ServiceFactory.php | 57 + database/factories/TalkFactory.php | 44 + database/factories/TechnologyFactory.php | 60 + database/factories/TestimonialFactory.php | 46 + database/factories/UserFactory.php | 59 + .../0001_01_01_000000_create_users_table.php | 69 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 59 + ..._08_12_180048_create_permission_tables.php | 134 + ...026_08_12_181000_create_content_tables.php | 73 + ..._08_13_090000_create_collective_tables.php | 181 + ..._08_13_090100_create_repository_tables.php | 105 + ...026_08_13_140000_create_settings_table.php | 36 + ...08_13_150000_create_applications_table.php | 77 + ...8_13_150100_create_notifications_table.php | 38 + .../2026_08_13_160000_create_media_table.php | 80 + ...160100_backfill_media_from_image_paths.php | 52 + ..._090000_create_image_attachments_table.php | 55 + ..._14_100000_add_unpublish_at_to_content.php | 45 + ...26_08_15_090000_create_showcase_tables.php | 169 + ..._08_15_090100_link_projects_to_clients.php | 100 + ...000_add_publication_window_to_services.php | 42 + ...26_08_15_110000_create_redirects_table.php | 56 + ...26_08_15_120000_create_revisions_table.php | 54 + ..._08_15_130000_add_logo_to_technologies.php | 31 + ...08_16_140000_create_missing_urls_table.php | 78 + ...2026_08_16_170000_create_review_tables.php | 90 + ...8_16_190000_create_content_views_table.php | 58 + database/seeders/DatabaseSeeder.php | 182 + database/seeders/RolePermissionSeeder.php | 54 + docker-compose.test.yml | 76 + docker-compose.yml | 143 + docker/Caddyfile | 53 + docker/entrypoint.sh | 91 + docker/php.ini | 28 + docs/build-pdf.mjs | 160 + docs/manual-de-uso.html | 1356 ++ docs/manual-de-uso.pdf | Bin 0 -> 524922 bytes lang/ca.json | 229 + lang/ca/actions.php | 119 + lang/ca/auth.php | 9 + lang/ca/console.php | 28 + lang/ca/docs.php | 7 + lang/ca/enums.php | 68 + lang/ca/health.php | 149 + lang/ca/http-statuses.php | 84 + lang/ca/maintenance.php | 34 + lang/ca/pagination.php | 8 + lang/ca/panel.php | 844 ++ lang/ca/passwords.php | 11 + lang/ca/pireon.php | 27 + lang/ca/rules.php | 31 + lang/ca/site.php | 20 + lang/ca/validation.php | 290 + lang/en.json | 229 + lang/en/actions.php | 119 + lang/en/auth.php | 9 + lang/en/console.php | 28 + lang/en/docs.php | 7 + lang/en/enums.php | 68 + lang/en/health.php | 149 + lang/en/http-statuses.php | 84 + lang/en/maintenance.php | 34 + lang/en/pagination.php | 8 + lang/en/panel.php | 842 ++ lang/en/passwords.php | 11 + lang/en/pireon.php | 27 + lang/en/rules.php | 31 + lang/en/site.php | 20 + lang/en/validation.php | 290 + lang/es.json | 229 + lang/es/actions.php | 119 + lang/es/auth.php | 9 + lang/es/console.php | 28 + lang/es/docs.php | 7 + lang/es/enums.php | 68 + lang/es/health.php | 149 + lang/es/http-statuses.php | 84 + lang/es/maintenance.php | 34 + lang/es/pagination.php | 8 + lang/es/panel.php | 844 ++ lang/es/passwords.php | 11 + lang/es/pireon.php | 27 + lang/es/rules.php | 31 + lang/es/site.php | 20 + lang/es/validation.php | 290 + package.json | 19 + phpunit.xml | 45 + public/.htaccess | 25 + public/api-types.d.ts | 401 + public/css/filament/filament/app.css | 2 + public/favicon.ico | 0 .../fonts/filament/filament/inter/index.css | 1 + ...er-cyrillic-ext-wght-normal-IYF56FF6.woff2 | Bin 0 -> 25960 bytes .../inter-cyrillic-wght-normal-JEOLYBOO.woff2 | Bin 0 -> 18748 bytes ...inter-greek-ext-wght-normal-EOVOK2B5.woff2 | Bin 0 -> 11232 bytes .../inter-greek-wght-normal-IRE366VL.woff2 | Bin 0 -> 18996 bytes ...inter-latin-ext-wght-normal-HA22NDSG.woff2 | Bin 0 -> 85068 bytes .../inter-latin-wght-normal-NRMW37G5.woff2 | Bin 0 -> 48256 bytes ...nter-vietnamese-wght-normal-CE5GGD3W.woff2 | Bin 0 -> 10252 bytes public/index.php | 20 + public/js/filament/actions/actions.js | 1 + public/js/filament/filament/app.js | 1 + public/js/filament/filament/echo.js | 13 + .../forms/components/checkbox-list.js | 1 + .../filament/forms/components/code-editor.js | 38 + .../filament/forms/components/color-picker.js | 1 + .../forms/components/date-time-picker.js | 1 + .../filament/forms/components/file-upload.js | 116 + .../js/filament/forms/components/key-value.js | 1 + .../forms/components/markdown-editor.js | 51 + .../filament/forms/components/rich-editor.js | 161 + public/js/filament/forms/components/select.js | 11 + public/js/filament/forms/components/slider.js | 1 + .../filament/forms/components/tags-input.js | 1 + .../js/filament/forms/components/textarea.js | 1 + .../filament/notifications/notifications.js | 1 + .../js/filament/schemas/components/actions.js | 1 + public/js/filament/schemas/components/tabs.js | 1 + .../js/filament/schemas/components/wizard.js | 1 + public/js/filament/schemas/schemas.js | 1 + public/js/filament/support/support.js | 46 + .../tables/components/columns/checkbox.js | 1 + .../tables/components/columns/select.js | 11 + .../tables/components/columns/text-input.js | 1 + .../tables/components/columns/toggle.js | 1 + public/js/filament/tables/tables.js | 1 + .../js/filament/widgets/components/chart.js | 31 + .../components/stats-overview/stat/chart.js | 20 + public/openapi.yaml | 2244 +++ public/robots.txt | 2 + public/vendor/swagger-ui/swagger-ui-bundle.js | 2 + .../swagger-ui-standalone-preset.js | 2 + public/vendor/swagger-ui/swagger-ui.css | 3 + resources/css/app.css | 9 + resources/js/app.js | 1 + resources/views/docs/api.blade.php | 65 + .../filament/pages/site-health.blade.php | 138 + .../filament/pages/site-maintenance.blade.php | 59 + .../views/filament/pages/trash.blade.php | 57 + .../partials/environment-banner.blade.php | 22 + .../partials/language-switcher.blade.php | 30 + .../filament/partials/login-footer.blade.php | 30 + .../filament/partials/preview-links.blade.php | 23 + .../views/filament/revisions/diff.blade.php | 44 + .../tables/columns/json-value.blade.php | 8 + resources/views/welcome.blade.php | 223 + routes/api.php | 175 + routes/console.php | 46 + routes/web.php | 61 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/FakesPireon.php | 94 + tests/Feature/Api/ApiDocsTest.php | 115 + tests/Feature/Api/ApiTypesTest.php | 84 + tests/Feature/Api/ContentViewsTest.php | 218 + tests/Feature/Api/PageApiTest.php | 268 + tests/Feature/Api/PublicApiContractTest.php | 275 + tests/Feature/Api/PublicApiTest.php | 293 + tests/Feature/Api/PublicImagesTest.php | 178 + tests/Feature/Api/ShowcaseApiTest.php | 269 + tests/Feature/Api/SubmissionApiTest.php | 349 + tests/Feature/Api/TelemetryApiTest.php | 301 + tests/Feature/Api/TokenEndpointTest.php | 121 + tests/Feature/Auth/PasswordLoginTest.php | 257 + tests/Feature/Auth/PireonLoginTest.php | 147 + tests/Feature/Git/GiteaSyncTest.php | 279 + tests/Feature/Git/RepositorySyncTest.php | 281 + tests/Feature/Media/ImageProcessingTest.php | 243 + tests/Feature/Media/ProjectGalleryTest.php | 153 + tests/Feature/Panel/ApplicationsInboxTest.php | 159 + tests/Feature/Panel/BulkPublishingTest.php | 154 + .../Feature/Panel/CollectiveResourcesTest.php | 155 + tests/Feature/Panel/ConcurrentEditingTest.php | 221 + tests/Feature/Panel/ContentFormsTest.php | 66 + tests/Feature/Panel/DocsNavigationTest.php | 73 + tests/Feature/Panel/DuplicateTitlesTest.php | 164 + tests/Feature/Panel/EnvironmentBannerTest.php | 68 + .../Panel/EveryPanelPageRendersTest.php | 77 + tests/Feature/Panel/MediaLibraryTest.php | 148 + tests/Feature/Panel/MissingUrlsTest.php | 249 + tests/Feature/Panel/PanelAccessTest.php | 140 + tests/Feature/Panel/PanelToolsTest.php | 166 + tests/Feature/Panel/ReviewsTest.php | 330 + tests/Feature/Panel/RevisionsTest.php | 198 + tests/Feature/Panel/SettingsResourceTest.php | 116 + tests/Feature/Panel/ShowcaseResourcesTest.php | 127 + tests/Feature/Panel/SiteHealthTest.php | 244 + tests/Feature/Panel/StaleTranslationsTest.php | 195 + tests/Feature/Panel/UserSessionsTest.php | 135 + tests/Feature/Site/ContentVersionTest.php | 96 + tests/Feature/Site/MaintenanceModeTest.php | 207 + .../Site/PruneAndRedirectHealthTest.php | 193 + tests/Feature/Site/PublicationWindowTest.php | 225 + tests/Feature/Site/RedirectsTest.php | 168 + tests/TestCase.php | 10 + tests/Unit/PermissionCatalogueTest.php | 87 + tests/Unit/TranslationCatalogueTest.php | 186 + vite.config.js | 24 + 534 files changed, 64050 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/Actions/Auth/SyncPireonUser.php create mode 100644 app/Auth/PireonTokenGuard.php create mode 100644 app/Console/Commands/AssignRole.php create mode 100644 app/Console/Commands/GenerateApiTypesCommand.php create mode 100644 app/Console/Commands/ProcessMediaCommand.php create mode 100644 app/Console/Commands/PruneContentCommand.php create mode 100644 app/Console/Commands/ScheduleContentCommand.php create mode 100644 app/Console/Commands/SiteCheckCommand.php create mode 100644 app/Console/Commands/SiteMaintenanceCommand.php create mode 100644 app/Console/Commands/SyncPermissions.php create mode 100644 app/Console/Commands/SyncRepositories.php create mode 100644 app/Enums/ApplicationKind.php create mode 100644 app/Enums/ApplicationStatus.php create mode 100644 app/Enums/CallStatus.php create mode 100644 app/Enums/GitProvider.php create mode 100644 app/Enums/MissingUrlStatus.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/ProjectStatus.php create mode 100644 app/Enums/RadarRing.php create mode 100644 app/Enums/ReviewEvent.php create mode 100644 app/Enums/TechnologyCategory.php create mode 100644 app/Filament/Concerns/WarnsOfConcurrentEditing.php create mode 100644 app/Filament/Pages/Auth/PireonLogin.php create mode 100644 app/Filament/Pages/SiteHealth.php create mode 100644 app/Filament/Pages/SiteMaintenance.php create mode 100644 app/Filament/Pages/Trash.php create mode 100644 app/Filament/RelationManagers/ReviewsRelationManager.php create mode 100644 app/Filament/RelationManagers/RevisionsRelationManager.php create mode 100644 app/Filament/Resources/Applications/ApplicationResource.php create mode 100644 app/Filament/Resources/Applications/Pages/ListApplications.php create mode 100644 app/Filament/Resources/Applications/Pages/ViewApplication.php create mode 100644 app/Filament/Resources/Applications/Schemas/ApplicationInfolist.php create mode 100644 app/Filament/Resources/Applications/Tables/ApplicationsTable.php create mode 100644 app/Filament/Resources/Calls/CallResource.php create mode 100644 app/Filament/Resources/Calls/Pages/CreateCall.php create mode 100644 app/Filament/Resources/Calls/Pages/EditCall.php create mode 100644 app/Filament/Resources/Calls/Pages/ListCalls.php create mode 100644 app/Filament/Resources/Calls/RelationManagers/ApplicationsRelationManager.php create mode 100644 app/Filament/Resources/Categories/CategoryResource.php create mode 100644 app/Filament/Resources/Categories/Pages/CreateCategory.php create mode 100644 app/Filament/Resources/Categories/Pages/EditCategory.php create mode 100644 app/Filament/Resources/Categories/Pages/ListCategories.php create mode 100644 app/Filament/Resources/Categories/Schemas/CategoryForm.php create mode 100644 app/Filament/Resources/Categories/Tables/CategoriesTable.php create mode 100644 app/Filament/Resources/Clients/ClientResource.php create mode 100644 app/Filament/Resources/Clients/Pages/CreateClient.php create mode 100644 app/Filament/Resources/Clients/Pages/EditClient.php create mode 100644 app/Filament/Resources/Clients/Pages/ListClients.php create mode 100644 app/Filament/Resources/Media/MediaResource.php create mode 100644 app/Filament/Resources/Media/Pages/ListMedia.php create mode 100644 app/Filament/Resources/Members/MemberResource.php create mode 100644 app/Filament/Resources/Members/Pages/CreateMember.php create mode 100644 app/Filament/Resources/Members/Pages/EditMember.php create mode 100644 app/Filament/Resources/Members/Pages/ListMembers.php create mode 100644 app/Filament/Resources/Members/Schemas/MemberForm.php create mode 100644 app/Filament/Resources/Members/Tables/MembersTable.php create mode 100644 app/Filament/Resources/MissingUrls/MissingUrlResource.php create mode 100644 app/Filament/Resources/MissingUrls/Pages/ListMissingUrls.php create mode 100644 app/Filament/Resources/Pages/PageResource.php create mode 100644 app/Filament/Resources/Pages/Pages/CreatePage.php create mode 100644 app/Filament/Resources/Pages/Pages/EditPage.php create mode 100644 app/Filament/Resources/Pages/Pages/ListPages.php create mode 100644 app/Filament/Resources/Pages/Schemas/PageForm.php create mode 100644 app/Filament/Resources/Pages/Tables/PagesTable.php create mode 100644 app/Filament/Resources/Projects/Pages/CreateProject.php create mode 100644 app/Filament/Resources/Projects/Pages/EditProject.php create mode 100644 app/Filament/Resources/Projects/Pages/ListProjects.php create mode 100644 app/Filament/Resources/Projects/ProjectResource.php create mode 100644 app/Filament/Resources/Projects/RelationManagers/GalleryRelationManager.php create mode 100644 app/Filament/Resources/Projects/RelationManagers/RepositoriesRelationManager.php create mode 100644 app/Filament/Resources/Projects/Schemas/ProjectForm.php create mode 100644 app/Filament/Resources/Projects/Tables/ProjectsTable.php create mode 100644 app/Filament/Resources/Redirects/Pages/CreateRedirect.php create mode 100644 app/Filament/Resources/Redirects/Pages/EditRedirect.php create mode 100644 app/Filament/Resources/Redirects/Pages/ListRedirects.php create mode 100644 app/Filament/Resources/Redirects/RedirectResource.php create mode 100644 app/Filament/Resources/Repositories/Pages/CreateRepository.php create mode 100644 app/Filament/Resources/Repositories/Pages/EditRepository.php create mode 100644 app/Filament/Resources/Repositories/Pages/ListRepositories.php create mode 100644 app/Filament/Resources/Repositories/RepositoryResource.php create mode 100644 app/Filament/Resources/Repositories/Tables/RepositoriesTable.php create mode 100644 app/Filament/Resources/RepositoryIssuesResource.php create mode 100644 app/Filament/Resources/RepositoryIssuesResource/Pages/EditRepositoryIssue.php create mode 100644 app/Filament/Resources/RepositoryIssuesResource/Pages/ListRepositoryIssues.php create mode 100644 app/Filament/Resources/RepositoryIssuesResource/Schemas/RepositoryIssueForm.php create mode 100644 app/Filament/Resources/RepositoryIssuesResource/Tables/RepositoryIssuesTable.php create mode 100644 app/Filament/Resources/Roles/Pages/CreateRole.php create mode 100644 app/Filament/Resources/Roles/Pages/EditRole.php create mode 100644 app/Filament/Resources/Roles/Pages/HandlesRolePermissions.php create mode 100644 app/Filament/Resources/Roles/Pages/ListRoles.php create mode 100644 app/Filament/Resources/Roles/RoleResource.php create mode 100644 app/Filament/Resources/Roles/Schemas/RoleForm.php create mode 100644 app/Filament/Resources/Roles/Tables/RolesTable.php create mode 100644 app/Filament/Resources/Services/Pages/CreateService.php create mode 100644 app/Filament/Resources/Services/Pages/EditService.php create mode 100644 app/Filament/Resources/Services/Pages/ListServices.php create mode 100644 app/Filament/Resources/Services/ServiceResource.php create mode 100644 app/Filament/Resources/SettingsResource.php create mode 100644 app/Filament/Resources/SettingsResource/Pages/EditSettings.php create mode 100644 app/Filament/Resources/SettingsResource/Pages/ListSettings.php create mode 100644 app/Filament/Resources/SettingsResource/Schemas/SettingsForm.php create mode 100644 app/Filament/Resources/SettingsResource/Tables/SettingsTable.php create mode 100644 app/Filament/Resources/Talks/Pages/CreateTalk.php create mode 100644 app/Filament/Resources/Talks/Pages/EditTalk.php create mode 100644 app/Filament/Resources/Talks/Pages/ListTalks.php create mode 100644 app/Filament/Resources/Talks/TalkResource.php create mode 100644 app/Filament/Resources/Technologies/Pages/CreateTechnology.php create mode 100644 app/Filament/Resources/Technologies/Pages/EditTechnology.php create mode 100644 app/Filament/Resources/Technologies/Pages/ListTechnologies.php create mode 100644 app/Filament/Resources/Technologies/TechnologyResource.php create mode 100644 app/Filament/Resources/Testimonials/Pages/CreateTestimonial.php create mode 100644 app/Filament/Resources/Testimonials/Pages/EditTestimonial.php create mode 100644 app/Filament/Resources/Testimonials/Pages/ListTestimonials.php create mode 100644 app/Filament/Resources/Testimonials/TestimonialResource.php create mode 100644 app/Filament/Resources/Users/Pages/EditUser.php create mode 100644 app/Filament/Resources/Users/Pages/ListUsers.php create mode 100644 app/Filament/Resources/Users/RelationManagers/SessionsRelationManager.php create mode 100644 app/Filament/Resources/Users/Schemas/UserForm.php create mode 100644 app/Filament/Resources/Users/Tables/UsersTable.php create mode 100644 app/Filament/Resources/Users/UserResource.php create mode 100644 app/Filament/Support/PreviewAction.php create mode 100644 app/Filament/Support/ReviewActions.php create mode 100644 app/Filament/Support/ReviewStatusColumn.php create mode 100644 app/Filament/Support/TranslatableTabs.php create mode 100644 app/Filament/Support/TranslationStatusColumn.php create mode 100644 app/Filament/Support/ViewsColumn.php create mode 100644 app/Filament/Widgets/AdvancedAnalytics.php create mode 100644 app/Filament/Widgets/ContentOverview.php create mode 100644 app/Filament/Widgets/GrowthChart.php create mode 100644 app/Filament/Widgets/LanguagesChart.php create mode 100644 app/Filament/Widgets/MostRead.php create mode 100644 app/Filament/Widgets/WhatNeedsYou.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/ApplicationController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/CallController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/MemberController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/PageController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/ProjectController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/PublicController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/ShowcaseController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/SiteController.php create mode 100644 app/Http/Controllers/Api/PublicApi/V1/TelemetryController.php create mode 100644 app/Http/Controllers/Api/V1/CategoryController.php create mode 100644 app/Http/Controllers/Api/V1/MeController.php create mode 100644 app/Http/Controllers/Api/V1/PageController.php create mode 100644 app/Http/Controllers/Api/V1/RoleController.php create mode 100644 app/Http/Controllers/Api/V1/TokenController.php create mode 100644 app/Http/Controllers/Api/V1/UserController.php create mode 100644 app/Http/Controllers/Auth/PireonAuthController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/DocsController.php create mode 100644 app/Http/Controllers/LocaleController.php create mode 100644 app/Http/Middleware/AllowPreview.php create mode 100644 app/Http/Middleware/CachePublicResponse.php create mode 100644 app/Http/Middleware/EnsureSiteIsAvailable.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 app/Http/Middleware/VerifyIngestToken.php create mode 100644 app/Http/Requests/Api/V1/CategoryRequest.php create mode 100644 app/Http/Requests/Api/V1/PageRequest.php create mode 100644 app/Http/Requests/PublicApi/ReportMissingUrlRequest.php create mode 100644 app/Http/Requests/PublicApi/ReportViewsRequest.php create mode 100644 app/Http/Requests/PublicApi/SubmitApplicationRequest.php create mode 100644 app/Http/Resources/CategoryResource.php create mode 100644 app/Http/Resources/PageResource.php create mode 100644 app/Http/Resources/PublicApi/PublicCallResource.php create mode 100644 app/Http/Resources/PublicApi/PublicCategoryResource.php create mode 100644 app/Http/Resources/PublicApi/PublicClientResource.php create mode 100644 app/Http/Resources/PublicApi/PublicIssueResource.php create mode 100644 app/Http/Resources/PublicApi/PublicMemberResource.php create mode 100644 app/Http/Resources/PublicApi/PublicPageResource.php create mode 100644 app/Http/Resources/PublicApi/PublicProjectResource.php create mode 100644 app/Http/Resources/PublicApi/PublicRepositoryResource.php create mode 100644 app/Http/Resources/PublicApi/PublicServiceResource.php create mode 100644 app/Http/Resources/PublicApi/PublicTalkResource.php create mode 100644 app/Http/Resources/PublicApi/PublicTechnologyResource.php create mode 100644 app/Http/Resources/PublicApi/PublicTestimonialResource.php create mode 100644 app/Http/Resources/RoleResource.php create mode 100644 app/Http/Resources/UserResource.php create mode 100644 app/Jobs/ProcessMedia.php create mode 100644 app/Jobs/SyncRepository.php create mode 100644 app/Models/Application.php create mode 100644 app/Models/Call.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Client.php create mode 100644 app/Models/Concerns/BumpsContentVersion.php create mode 100644 app/Models/Concerns/HasContentViews.php create mode 100644 app/Models/Concerns/HasImages.php create mode 100644 app/Models/Concerns/HasPublicationWindow.php create mode 100644 app/Models/Concerns/HasReviews.php create mode 100644 app/Models/Concerns/HasRevisions.php create mode 100644 app/Models/Concerns/RecordsSlugChanges.php create mode 100644 app/Models/ContentView.php create mode 100644 app/Models/ImageAttachment.php create mode 100644 app/Models/Media.php create mode 100644 app/Models/Member.php create mode 100644 app/Models/MissingUrl.php create mode 100644 app/Models/Page.php create mode 100644 app/Models/Project.php create mode 100644 app/Models/Redirect.php create mode 100644 app/Models/Repository.php create mode 100644 app/Models/RepositoryIssue.php create mode 100644 app/Models/ReviewComment.php create mode 100644 app/Models/Revision.php create mode 100644 app/Models/Service.php create mode 100644 app/Models/Setting.php create mode 100644 app/Models/Talk.php create mode 100644 app/Models/Technology.php create mode 100644 app/Models/Testimonial.php create mode 100644 app/Models/User.php create mode 100644 app/Models/UserSession.php create mode 100644 app/Notifications/ApplicationReceived.php create mode 100644 app/Notifications/ReviewRequested.php create mode 100644 app/Notifications/ReviewReturned.php create mode 100644 app/Policies/ApplicationPolicy.php create mode 100644 app/Policies/CallPolicy.php create mode 100644 app/Policies/CategoryPolicy.php create mode 100644 app/Policies/ClientPolicy.php create mode 100644 app/Policies/MediaPolicy.php create mode 100644 app/Policies/MemberPolicy.php create mode 100644 app/Policies/MissingUrlPolicy.php create mode 100644 app/Policies/PagePolicy.php create mode 100644 app/Policies/ProjectPolicy.php create mode 100644 app/Policies/RedirectPolicy.php create mode 100644 app/Policies/RepositoryPolicy.php create mode 100644 app/Policies/ResourcePolicy.php create mode 100644 app/Policies/RolePolicy.php create mode 100644 app/Policies/ServicePolicy.php create mode 100644 app/Policies/TalkPolicy.php create mode 100644 app/Policies/TechnologyPolicy.php create mode 100644 app/Policies/TestimonialPolicy.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Providers/Filament/AdminPanelProvider.php create mode 100644 app/Rules/Translatable.php create mode 100644 app/Rules/UniqueTranslatedSlug.php create mode 100644 app/Services/Git/Contracts/RepositoryProvider.php create mode 100644 app/Services/Git/GitException.php create mode 100644 app/Services/Git/GitHubProvider.php create mode 100644 app/Services/Git/GitManager.php create mode 100644 app/Services/Git/GiteaProvider.php create mode 100644 app/Services/Git/IssueSnapshot.php create mode 100644 app/Services/Git/RepositorySnapshot.php create mode 100644 app/Services/Media/ImageProcessor.php create mode 100644 app/Services/Pireon/AccessToken.php create mode 100644 app/Services/Pireon/PireonClient.php create mode 100644 app/Services/Pireon/PireonException.php create mode 100644 app/Services/Pireon/PireonIdentity.php create mode 100644 app/Services/Pireon/TokenStore.php create mode 100644 app/Support/ContentTypes.php create mode 100644 app/Support/ContentVersion.php create mode 100644 app/Support/DuplicateTitles.php create mode 100644 app/Support/Health/Check.php create mode 100644 app/Support/Health/Checks/BrokenImages.php create mode 100644 app/Support/Health/Checks/BrokenRedirects.php create mode 100644 app/Support/Health/Checks/ClientsWithoutLogo.php create mode 100644 app/Support/Health/Checks/DuplicateSlugs.php create mode 100644 app/Support/Health/Checks/ExpiredCalls.php create mode 100644 app/Support/Health/Checks/ImagesWithoutAlt.php create mode 100644 app/Support/Health/Checks/IncompleteSeo.php create mode 100644 app/Support/Health/Checks/MissingSlugs.php create mode 100644 app/Support/Health/Checks/MissingTranslations.php create mode 100644 app/Support/Health/Checks/RedirectsToNowhere.php create mode 100644 app/Support/Health/Checks/ServicesWithoutWork.php create mode 100644 app/Support/Health/Checks/StaleRepositories.php create mode 100644 app/Support/Health/Checks/StaleReviews.php create mode 100644 app/Support/Health/Checks/StaleTranslations.php create mode 100644 app/Support/Health/Checks/StalledMediaQueue.php create mode 100644 app/Support/Health/Checks/TalksWithoutSpeaker.php create mode 100644 app/Support/Health/Checks/TestimonialsMissingOriginal.php create mode 100644 app/Support/Health/Checks/UnansweredSubmissions.php create mode 100644 app/Support/Health/Checks/UnresolvedMissingUrls.php create mode 100644 app/Support/Health/Finding.php create mode 100644 app/Support/Health/HealthReport.php create mode 100644 app/Support/Health/Severity.php create mode 100644 app/Support/ImagePayload.php create mode 100644 app/Support/Maintenance.php create mode 100644 app/Support/Permissions.php create mode 100644 app/Support/Presence.php create mode 100644 app/Support/PreviewLink.php create mode 100644 app/Support/ReviewTarget.php create mode 100644 app/Support/SiteUrls.php create mode 100644 app/Support/TranslatablePayload.php create mode 100644 app/Support/TranslationFreshness.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/git.php create mode 100644 config/locales.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/media.php create mode 100644 config/permission.php create mode 100644 config/pireon.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/site.php create mode 100644 database/.gitignore create mode 100644 database/factories/ApplicationFactory.php create mode 100644 database/factories/CallFactory.php create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/factories/ClientFactory.php create mode 100644 database/factories/ImageAttachmentFactory.php create mode 100644 database/factories/MediaFactory.php create mode 100644 database/factories/MemberFactory.php create mode 100644 database/factories/MissingUrlFactory.php create mode 100644 database/factories/PageFactory.php create mode 100644 database/factories/ProjectFactory.php create mode 100644 database/factories/RedirectFactory.php create mode 100644 database/factories/RepositoryFactory.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/factories/TalkFactory.php create mode 100644 database/factories/TechnologyFactory.php create mode 100644 database/factories/TestimonialFactory.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_08_12_180048_create_permission_tables.php create mode 100644 database/migrations/2026_08_12_181000_create_content_tables.php create mode 100644 database/migrations/2026_08_13_090000_create_collective_tables.php create mode 100644 database/migrations/2026_08_13_090100_create_repository_tables.php create mode 100644 database/migrations/2026_08_13_140000_create_settings_table.php create mode 100644 database/migrations/2026_08_13_150000_create_applications_table.php create mode 100644 database/migrations/2026_08_13_150100_create_notifications_table.php create mode 100644 database/migrations/2026_08_13_160000_create_media_table.php create mode 100644 database/migrations/2026_08_13_160100_backfill_media_from_image_paths.php create mode 100644 database/migrations/2026_08_14_090000_create_image_attachments_table.php create mode 100644 database/migrations/2026_08_14_100000_add_unpublish_at_to_content.php create mode 100644 database/migrations/2026_08_15_090000_create_showcase_tables.php create mode 100644 database/migrations/2026_08_15_090100_link_projects_to_clients.php create mode 100644 database/migrations/2026_08_15_100000_add_publication_window_to_services.php create mode 100644 database/migrations/2026_08_15_110000_create_redirects_table.php create mode 100644 database/migrations/2026_08_15_120000_create_revisions_table.php create mode 100644 database/migrations/2026_08_15_130000_add_logo_to_technologies.php create mode 100644 database/migrations/2026_08_16_140000_create_missing_urls_table.php create mode 100644 database/migrations/2026_08_16_170000_create_review_tables.php create mode 100644 database/migrations/2026_08_16_190000_create_content_views_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/RolePermissionSeeder.php create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docker/Caddyfile create mode 100644 docker/entrypoint.sh create mode 100644 docker/php.ini create mode 100644 docs/build-pdf.mjs create mode 100644 docs/manual-de-uso.html create mode 100644 docs/manual-de-uso.pdf create mode 100644 lang/ca.json create mode 100644 lang/ca/actions.php create mode 100644 lang/ca/auth.php create mode 100644 lang/ca/console.php create mode 100644 lang/ca/docs.php create mode 100644 lang/ca/enums.php create mode 100644 lang/ca/health.php create mode 100644 lang/ca/http-statuses.php create mode 100644 lang/ca/maintenance.php create mode 100644 lang/ca/pagination.php create mode 100644 lang/ca/panel.php create mode 100644 lang/ca/passwords.php create mode 100644 lang/ca/pireon.php create mode 100644 lang/ca/rules.php create mode 100644 lang/ca/site.php create mode 100644 lang/ca/validation.php create mode 100644 lang/en.json create mode 100644 lang/en/actions.php create mode 100644 lang/en/auth.php create mode 100644 lang/en/console.php create mode 100644 lang/en/docs.php create mode 100644 lang/en/enums.php create mode 100644 lang/en/health.php create mode 100644 lang/en/http-statuses.php create mode 100644 lang/en/maintenance.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/panel.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/pireon.php create mode 100644 lang/en/rules.php create mode 100644 lang/en/site.php create mode 100644 lang/en/validation.php create mode 100644 lang/es.json create mode 100644 lang/es/actions.php create mode 100644 lang/es/auth.php create mode 100644 lang/es/console.php create mode 100644 lang/es/docs.php create mode 100644 lang/es/enums.php create mode 100644 lang/es/health.php create mode 100644 lang/es/http-statuses.php create mode 100644 lang/es/maintenance.php create mode 100644 lang/es/pagination.php create mode 100644 lang/es/panel.php create mode 100644 lang/es/passwords.php create mode 100644 lang/es/pireon.php create mode 100644 lang/es/rules.php create mode 100644 lang/es/site.php create mode 100644 lang/es/validation.php create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/api-types.d.ts create mode 100644 public/css/filament/filament/app.css create mode 100644 public/favicon.ico create mode 100644 public/fonts/filament/filament/inter/index.css create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2 create mode 100644 public/index.php create mode 100644 public/js/filament/actions/actions.js create mode 100644 public/js/filament/filament/app.js create mode 100644 public/js/filament/filament/echo.js create mode 100644 public/js/filament/forms/components/checkbox-list.js create mode 100644 public/js/filament/forms/components/code-editor.js create mode 100644 public/js/filament/forms/components/color-picker.js create mode 100644 public/js/filament/forms/components/date-time-picker.js create mode 100644 public/js/filament/forms/components/file-upload.js create mode 100644 public/js/filament/forms/components/key-value.js create mode 100644 public/js/filament/forms/components/markdown-editor.js create mode 100644 public/js/filament/forms/components/rich-editor.js create mode 100644 public/js/filament/forms/components/select.js create mode 100644 public/js/filament/forms/components/slider.js create mode 100644 public/js/filament/forms/components/tags-input.js create mode 100644 public/js/filament/forms/components/textarea.js create mode 100644 public/js/filament/notifications/notifications.js create mode 100644 public/js/filament/schemas/components/actions.js create mode 100644 public/js/filament/schemas/components/tabs.js create mode 100644 public/js/filament/schemas/components/wizard.js create mode 100644 public/js/filament/schemas/schemas.js create mode 100644 public/js/filament/support/support.js create mode 100644 public/js/filament/tables/components/columns/checkbox.js create mode 100644 public/js/filament/tables/components/columns/select.js create mode 100644 public/js/filament/tables/components/columns/text-input.js create mode 100644 public/js/filament/tables/components/columns/toggle.js create mode 100644 public/js/filament/tables/tables.js create mode 100644 public/js/filament/widgets/components/chart.js create mode 100644 public/js/filament/widgets/components/stats-overview/stat/chart.js create mode 100644 public/openapi.yaml create mode 100644 public/robots.txt create mode 100644 public/vendor/swagger-ui/swagger-ui-bundle.js create mode 100644 public/vendor/swagger-ui/swagger-ui-standalone-preset.js create mode 100644 public/vendor/swagger-ui/swagger-ui.css create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/views/docs/api.blade.php create mode 100644 resources/views/filament/pages/site-health.blade.php create mode 100644 resources/views/filament/pages/site-maintenance.blade.php create mode 100644 resources/views/filament/pages/trash.blade.php create mode 100644 resources/views/filament/partials/environment-banner.blade.php create mode 100644 resources/views/filament/partials/language-switcher.blade.php create mode 100644 resources/views/filament/partials/login-footer.blade.php create mode 100644 resources/views/filament/partials/preview-links.blade.php create mode 100644 resources/views/filament/revisions/diff.blade.php create mode 100644 resources/views/filament/tables/columns/json-value.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/FakesPireon.php create mode 100644 tests/Feature/Api/ApiDocsTest.php create mode 100644 tests/Feature/Api/ApiTypesTest.php create mode 100644 tests/Feature/Api/ContentViewsTest.php create mode 100644 tests/Feature/Api/PageApiTest.php create mode 100644 tests/Feature/Api/PublicApiContractTest.php create mode 100644 tests/Feature/Api/PublicApiTest.php create mode 100644 tests/Feature/Api/PublicImagesTest.php create mode 100644 tests/Feature/Api/ShowcaseApiTest.php create mode 100644 tests/Feature/Api/SubmissionApiTest.php create mode 100644 tests/Feature/Api/TelemetryApiTest.php create mode 100644 tests/Feature/Api/TokenEndpointTest.php create mode 100644 tests/Feature/Auth/PasswordLoginTest.php create mode 100644 tests/Feature/Auth/PireonLoginTest.php create mode 100644 tests/Feature/Git/GiteaSyncTest.php create mode 100644 tests/Feature/Git/RepositorySyncTest.php create mode 100644 tests/Feature/Media/ImageProcessingTest.php create mode 100644 tests/Feature/Media/ProjectGalleryTest.php create mode 100644 tests/Feature/Panel/ApplicationsInboxTest.php create mode 100644 tests/Feature/Panel/BulkPublishingTest.php create mode 100644 tests/Feature/Panel/CollectiveResourcesTest.php create mode 100644 tests/Feature/Panel/ConcurrentEditingTest.php create mode 100644 tests/Feature/Panel/ContentFormsTest.php create mode 100644 tests/Feature/Panel/DocsNavigationTest.php create mode 100644 tests/Feature/Panel/DuplicateTitlesTest.php create mode 100644 tests/Feature/Panel/EnvironmentBannerTest.php create mode 100644 tests/Feature/Panel/EveryPanelPageRendersTest.php create mode 100644 tests/Feature/Panel/MediaLibraryTest.php create mode 100644 tests/Feature/Panel/MissingUrlsTest.php create mode 100644 tests/Feature/Panel/PanelAccessTest.php create mode 100644 tests/Feature/Panel/PanelToolsTest.php create mode 100644 tests/Feature/Panel/ReviewsTest.php create mode 100644 tests/Feature/Panel/RevisionsTest.php create mode 100644 tests/Feature/Panel/SettingsResourceTest.php create mode 100644 tests/Feature/Panel/ShowcaseResourcesTest.php create mode 100644 tests/Feature/Panel/SiteHealthTest.php create mode 100644 tests/Feature/Panel/StaleTranslationsTest.php create mode 100644 tests/Feature/Panel/UserSessionsTest.php create mode 100644 tests/Feature/Site/ContentVersionTest.php create mode 100644 tests/Feature/Site/MaintenanceModeTest.php create mode 100644 tests/Feature/Site/PruneAndRedirectHealthTest.php create mode 100644 tests/Feature/Site/PublicationWindowTest.php create mode 100644 tests/Feature/Site/RedirectsTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/PermissionCatalogueTest.php create mode 100644 tests/Unit/TranslationCatalogueTest.php create mode 100644 vite.config.js 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/**'], + }, + }, +});