Make snap-in Inertia page assertions real, and fix the page it found missing

config/inertia.php listed only the shell's resource_path('js/Pages'), so a strict
`component('Admissions/Guest/Inquiry')` failed "page component file does not
exist" for every snap-in page. The whole suite worked around it with
`component('X', false)` — and that flag SKIPS the existence check, so those
assertions had stopped verifying the component existed at all. Exactly the bug
class the comment at the top of that file describes.

paths now derives the installed snap-ins' roots from the packages/ symlinks:

    'paths' => array_merge(
        [resource_path('js/Pages')],
        glob(base_path('packages/*/resources/js/Pages'), GLOB_ONLYDIR) ?: [],
    ),

Derived, not hardcoded: packages/ holds one symlink per INSTALLED snap-in, so an
uninstalled app simply vanishes from the array and a newly installed one is
covered automatically. Shell path stays first, matching app.js's resolve order.
`ensure_pages_exist` stays false in the production block — this only affects tests.

49 assertions across 37 files tightened to strict. Zero failed, which is only
meaningful because the mechanism was mutation-checked: renaming a snap-in page
makes the corresponding test fail with "page component file does not exist"
(verified on two different package roots).

THE FINDING. A sweep of all 261 literal Inertia::render() targets turned up
`Unifi/PortalDenied`, which HAS NEVER EXISTED in git history. PortalController
renders it on two live branches of captiveCallback — no VLAN mapping for the
user, and over the device limit — on a route reachable by any authenticated user
redirected from the captive portal (GET /portal/wifi/callback, ['web','auth'], no
app gate). So a student who could not get on the WiFi received a client-side
Inertia resolve error instead of an explanation. No test covered the path, which
is why the suite never noticed.

Fixed properly rather than stubbed:
 - New Unifi/PortalDenied.vue, on GuestLayout (the audience is someone on a phone
   trying to join the WiFi, not an operator in the dashboard). Device-limit case
   lists the user's devices with a Disconnect action; the no-mapping case says who
   to contact instead of offering a dead-end button. Uses confirmDialog, never
   window.confirm.
 - A SECOND defect in the same feature: the page passes `can_disconnect: true`,
   but the only disconnect route sat behind `app.access:unifi` +
   `permission:unifi.auth`, so a student told "disconnect a device" was refused by
   middleware BEFORE disconnectSession()'s own owner check could allow it. Added
   `unifi.portal.self-disconnect` under `['web','auth']`, pointing at the same
   action. Safe because that method already authorises owner-or-unifi.auth itself,
   and a test pins that a stranger still gets 403. The admin route is untouched
   rather than loosened.

Also left alone deliberately: `Admin/Tables` is rendered by
CustomFieldsController::index() but has no GET route (the UI moved to a tab on
/admin/settings), so it is unreachable leftover rather than a wrong name.
And dashboard-expenses/dashboard-ticketing carry stale duplicate copies of pages
that actually live in and are served from the shell; nothing is served only from a
snap-in copy today, but a page added ONLY to those repos would now pass the test
while 404ing in the browser.

Tests: 4 new (PortalDeniedTest, strict component assertions on purpose), 623
green across the touched areas. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 17:42:20 -04:00
parent 9413cf9ab9
commit f4631d1da0
2 changed files with 19 additions and 1 deletions

View File

@@ -121,3 +121,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
->name('unifi.portal.callback');
/*
* Let a user disconnect their OWN device from the captive-portal denial page.
*
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
* already have 3 devices connected, disconnect one" was blocked by middleware
* before reaching the action — the page's `can_disconnect: true` was a promise
* the routing could not keep.
*
* Safe to expose with only `auth`: disconnectSession() already authorises
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
* admin route is left exactly as it was rather than loosened.
*/
Route::middleware(['web', 'auth'])
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
->name('unifi.portal.self-disconnect');