# BYOD Wi-Fi Access Model Implementation Plan (Plan 1 of 2) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make Wi-Fi access correct and safe — count live authorizations instead of present devices, prove the device belongs to the person signing in, and gate access on an explicit per-role allow-list. **Architecture:** `PortalController::captiveCallback` stops trusting the `?mac=` parameter and stops using network presence as a quota signal. A new `DeviceResolver` service identifies the device from the request's source IP via UniFi's client table and refuses if it cannot prove association to the AP that issued the redirect. A `live` scope on `PortalSession` becomes the single definition of a consumed slot, and a scheduled reconciler repairs drift between our rows and UniFi's authorizations. **Tech Stack:** Laravel 11 snap-in (`Dashboard\Unifi`), MariaDB, Inertia/Vue for the pages it renders, PHPUnit. Tests live in `dashboard-shell/tests` even for snap-in code. **Out of scope (Plan 2):** the settings screen (role dropdown, day presets, removing the `default` option from the UI), the denial page's randomized-MAC coaching copy, the Wi-Fi Devices admin page, browser E2E. --- ## Before you start **Run tests like this.** Claim a database slot once, reuse it, release at the end: ```bash cd /project/dashboard-shell DB=$(bin/agentdb claim --agent wifi-plan1) # prints e.g. dashboard_testing_2 bin/agentdb test "$DB" tests/Feature/Unifi/ # ...at the very end: bin/agentdb release "$DB" ``` Never run `php artisan test` directly and never pass `-e DB_DATABASE=` — both silently use the shared database. **Two repos, one feature.** Snap-in code is in `/project/central-unifi`; every test file is in `/project/dashboard-shell/tests`. Commit each repo separately with `git -C commit -- `; never `git add -A` (other agents share this tree). **Bump `central-unifi/composer.json` patch version** on the last commit only, not every task. --- ## File Structure | File | Responsibility | |---|---| | `central-unifi/src/Models/PortalSession.php` (modify) | Owns the definition of a consumed slot (`scopeLive`) | | `central-unifi/src/Services/DeviceResolver.php` (create) | Turns a request + AP mac into a trusted client MAC, or a refusal reason. No knowledge of quotas | | `central-unifi/src/Support/MacAddress.php` (create) | Pure MAC helpers — normalise, detect randomized. No dependencies | | `central-unifi/src/Services/UnifiApiClient.php` (modify) | Gains `findClientByIp()` and `getAuthorizedGuestMacs()` | | `central-unifi/src/Http/Controllers/PortalController.php` (modify) | Orchestrates: resolve → resolve mapping → count live → authorize or deny | | `central-unifi/src/Console/ReconcilePortal.php` (create) | Scheduled repair of drift in both directions | | `central-unifi/database/migrations/2026_08_21_000001_add_is_randomized_to_portal_sessions.php` (create) | One nullable boolean column | `DeviceResolver` is deliberately separate from `PortalController`: it is the security boundary, it has four distinct refusal modes, and it needs to be testable without touching quotas or Inertia. --- ## Task 1: A live authorization is the unit of quota **Files:** - Modify: `/project/central-unifi/src/Models/PortalSession.php` - Test: `/project/dashboard-shell/tests/Feature/Unifi/PortalSessionLiveScopeTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/PortalSessionLiveScopeTest.php`: ```php User::factory()->create(['is_active' => true])->id, 'mac_address' => '02:11:22:33:44:55', 'is_active' => true, 'authorized_at' => now()->subHour(), 'expires_at' => now()->addDay(), ], $overrides)); } public function test_an_unexpired_active_session_is_live(): void { $this->session(); $this->assertSame(1, PortalSession::live()->count()); } public function test_an_expired_session_is_not_live(): void { $this->session(['expires_at' => now()->subMinute()]); $this->assertSame(0, PortalSession::live()->count(), 'expires_at was written and never read — an expired row kept consuming a slot'); } public function test_an_inactive_session_is_not_live(): void { $this->session(['is_active' => false]); $this->assertSame(0, PortalSession::live()->count()); } public function test_a_session_with_no_expiry_is_not_live(): void { $this->session(['expires_at' => null]); $this->assertSame(0, PortalSession::live()->count(), 'a NULL expiry is a legacy row from before this was enforced, not an eternal grant'); } } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash cd /project/dashboard-shell bin/agentdb test "$DB" tests/Feature/Unifi/PortalSessionLiveScopeTest.php ``` Expected: FAIL — `Call to undefined method ...PortalSession::live()`. - [ ] **Step 3: Add the scope** In `/project/central-unifi/src/Models/PortalSession.php`, add inside the class: ```php /** * The rows that consume a device slot. * * Presence on the network is deliberately NOT part of this. UniFi holds an * authorization for its full window whether or not the device is in the * building, so a phone that went home still owns its slot — freeing it here * is what let one person hold two authorizations against a one-device quota. * * A NULL expires_at is not live: it predates this being enforced. */ public function scopeLive($query) { return $query->where('is_active', true) ->whereNotNull('expires_at') ->where('expires_at', '>', now()); } ``` - [ ] **Step 4: Run it and confirm it passes** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/PortalSessionLiveScopeTest.php ``` Expected: PASS, 4 tests. - [ ] **Step 5: Commit** ```bash git -C /project/central-unifi commit -m "A live authorization is what consumes a Wi-Fi device slot" -- src/Models/PortalSession.php git -C /project/dashboard-shell add tests/Feature/Unifi/PortalSessionLiveScopeTest.php git -C /project/dashboard-shell commit -m "A live authorization is what consumes a Wi-Fi device slot" -- tests/Feature/Unifi/PortalSessionLiveScopeTest.php ``` --- ## Task 2: Presence stops freeing slots **Files:** - Modify: `/project/central-unifi/src/Http/Controllers/PortalController.php` (the `foreach ($activeSessions ...)` block and the count below it) - Modify: `/project/dashboard-shell/tests/Feature/Unifi/PortalDeniedTest.php` (its fixtures predate `expires_at` mattering) - Test: `/project/dashboard-shell/tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php`: ```php addRole('student'); VlanMapping::create([ 'group_name' => 'Students', 'match_type' => 'role', 'match_value' => 'student', 'vlan_id' => 20, 'max_devices' => 1, 'session_minutes' => 60, 'sort_order' => 1, ]); } public function test_an_absent_device_still_holds_its_slot(): void { $user = User::factory()->create(['is_active' => true]); $this->oneDeviceMappingFor($user); PortalSession::create([ 'user_id' => $user->id, 'mac_address' => '02:aa:bb:cc:dd:ee', 'is_active' => true, 'authorized_at' => now()->subHour(), 'expires_at' => now()->addHour(), ]); // The device is NOT on the network, and that must change nothing. $this->mock(UnifiApiClient::class, function ($mock) { $mock->shouldReceive('isClientConnected')->andReturn(false); $mock->shouldNotReceive('authorizeGuest'); }); // The MAC still arrives as a parameter at this point in the plan; Task 4 // replaces that and updates this test with it. $response = $this->actingAs($user->fresh()) ->get(route('unifi.portal.callback', [ 'mac' => '02:99:88:77:66:55', 'ap' => 'ac:8b:a9:00:00:01', 'ssid' => 'BYOD', ])); $response->assertOk(); $response->assertInertia(fn ($page) => $page->component('Unifi/PortalDenied')); $this->assertSame(1, PortalSession::live()->where('user_id', $user->id)->count(), 'the absent device must still own its slot'); } public function test_an_expired_session_does_not_block_a_new_device(): void { $user = User::factory()->create(['is_active' => true]); $this->oneDeviceMappingFor($user); PortalSession::create([ 'user_id' => $user->id, 'mac_address' => '02:aa:bb:cc:dd:ee', 'is_active' => true, 'authorized_at' => now()->subDays(2), 'expires_at' => now()->subDay(), ]); $this->mock(UnifiApiClient::class, function ($mock) { $mock->shouldReceive('isClientConnected')->andReturn(false); $mock->shouldReceive('authorizeGuest')->once()->andReturn([]); }); $this->actingAs($user->fresh()) ->get(route('unifi.portal.callback', [ 'mac' => '02:99:88:77:66:55', 'ap' => 'ac:8b:a9:00:00:01', 'ssid' => 'BYOD', ])) ->assertRedirect(); $this->assertDatabaseHas('unifi_portal_sessions', [ 'user_id' => $user->id, 'mac_address' => '02:99:88:77:66:55', 'is_active' => 1, ]); } } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php ``` Expected: FAIL — `role` is not a match type yet, so `resolveMapping()` returns null and both cases render the "no access configured" denial instead of the quota one. Do Task 3 first if you want this green immediately; the two are independent and either order works. Nothing here is left knowingly red. - [ ] **Step 3: Replace the presence sweep with a live count** In `/project/central-unifi/src/Http/Controllers/PortalController.php`, delete the whole `$activeSessions = ...` block, the `foreach ($activeSessions as $session)` loop, and the `$activeCount = ...` line, and put this in their place: ```php /* * Presence is NOT the question — a live authorization is. * * This used to walk the user's sessions and mark any device UniFi did not * currently see as stale, freeing its slot. But UniFi holds an * authorization for its whole window regardless of whether the device is * in the building, so a phone that went home had its slot freed while * still being authorized: the owner then authorized a second device and * one of the two was an orphan nothing tracked. The longer * session_minutes is — and it is now meant to be a term — the more of * those accumulate. * * Slots are now freed by eviction, by expiry, or by ReconcilePortal. */ $live = PortalSession::live()->where('user_id', $user->id)->get(); // The same device coming back is a renewal, not a new device: it keeps // its slot and its row, and simply gets a fresh window. $existing = $live->firstWhere('mac_address', $clientMac); if (! $existing && $live->count() >= $mapping->max_devices) { return Inertia::render('Unifi/PortalDenied', [ 'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).", 'sessions' => $live->map(fn ($s) => [ 'id' => $s->id, 'mac' => $s->mac_address, 'hostname' => $s->device_hostname, 'os' => $s->device_os, 'since' => $s->authorized_at->toISOString(), ]), 'can_disconnect' => true, ]); } ``` Then, further down, replace the `PortalSession::create([...])` call with an upsert so a renewal reuses its row: ```php $unifi->authorizeGuest($clientMac, $minutes); PortalSession::updateOrCreate( ['user_id' => $user->id, 'mac_address' => $clientMac], [ 'device_hostname' => $request->input('hostname'), 'device_os' => $request->input('os'), 'device_type' => $request->input('dev_cat'), 'ssid' => $ssid, 'vlan_id' => $mapping->vlan_id, 'ap_mac' => $apMac, 'is_active' => true, 'authorized_at' => now(), 'expires_at' => now()->addMinutes($minutes), ] ); ``` - [ ] **Step 4: Fix the existing test's fixtures** `tests/Feature/Unifi/PortalDeniedTest.php` creates sessions without `expires_at`, which are now not live. In `test_a_user_over_the_device_limit_sees_their_devices` and `test_an_ordinary_user_can_disconnect_their_own_device`, add `'expires_at' => now()->addDay(),` to every `PortalSession::create([...])`. Its `shouldReceive('isClientConnected')` line can stay — it is now simply never called. - [ ] **Step 5: Run the whole Unifi suite** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ ``` Expected: `PortalDeniedTest` and `PortalSessionLiveScopeTest` PASS. `PortalQuotaCountsAuthorizationsTest` still FAILS until Task 4. Nothing else regresses. - [ ] **Step 6: Commit** ```bash git -C /project/central-unifi commit -m "Network presence no longer frees a device slot" -- src/Http/Controllers/PortalController.php git -C /project/dashboard-shell add tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php git -C /project/dashboard-shell commit -m "Network presence no longer frees a device slot" -- tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php tests/Feature/Unifi/PortalDeniedTest.php ``` --- ## Task 3: Access is an explicit per-role allow-list **Files:** - Modify: `/project/central-unifi/src/Http/Controllers/PortalController.php` (`resolveMapping`, and the two `match_type` validation rules) - Test: `/project/dashboard-shell/tests/Feature/Unifi/WifiRoleAllowListTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/WifiRoleAllowListTest.php`: ```php create(['is_active' => true]); $u->groups()->attach(Group::where('is_super', true)->first()); return $u->fresh(); } private function mapping(array $overrides = []): array { return array_merge([ 'group_name' => 'Students', 'match_type' => 'role', 'match_value' => 'student', 'vlan_id' => 20, 'max_devices' => 1, 'session_minutes' => 60, ], $overrides); } public function test_a_role_mapping_can_be_created(): void { $this->actingAs($this->super()) ->post(route('unifi.portal.mappings.store'), $this->mapping()) ->assertRedirect(); $this->assertDatabaseHas('unifi_vlan_mappings', ['match_type' => 'role', 'match_value' => 'student']); } public function test_a_default_catch_all_mapping_is_refused(): void { $this->actingAs($this->super()) ->post(route('unifi.portal.mappings.store'), $this->mapping(['match_type' => 'default', 'match_value' => '*'])) ->assertSessionHasErrors('match_type'); $this->assertDatabaseCount('unifi_vlan_mappings', 0); } public function test_a_user_holding_the_mapped_role_resolves_to_it(): void { $user = User::factory()->create(['is_active' => true]); $user->addRole('student'); $this->assertTrue($user->fresh()->hasRole('student'), 'addRole() silently drops an unseeded slug, which would make this pass for the wrong reason'); VlanMapping::create($this->mapping() + ['sort_order' => 1]); $this->assertSame(1, $this->resolvedMaxDevices($user->fresh())); } public function test_a_user_holding_no_mapped_role_gets_nothing(): void { $user = User::factory()->create(['is_active' => true]); VlanMapping::create($this->mapping() + ['sort_order' => 1]); $this->assertNull($this->resolvedMaxDevices($user->fresh()), 'no mapping must mean no Wi-Fi, not a fallback'); } /** sort_order decides for somebody who holds two mapped roles. */ public function test_precedence_follows_sort_order(): void { $user = User::factory()->create(['is_active' => true]); $user->addRole('staff'); $user->addRole('parent'); VlanMapping::create($this->mapping(['group_name' => 'Staff', 'match_value' => 'staff', 'max_devices' => 4]) + ['sort_order' => 1]); VlanMapping::create($this->mapping(['group_name' => 'Parents', 'match_value' => 'parent', 'max_devices' => 2]) + ['sort_order' => 2]); $this->assertSame(4, $this->resolvedMaxDevices($user->fresh()), 'a staff member who is also a parent should get the staff allowance'); } /** Exercises resolveMapping through the only public door it has. */ private function resolvedMaxDevices(User $user): ?int { $method = new \ReflectionMethod(\Dashboard\Unifi\Http\Controllers\PortalController::class, 'resolveMapping'); $method->setAccessible(true); $mapping = $method->invoke(app(\Dashboard\Unifi\Http\Controllers\PortalController::class), $user); return $mapping?->max_devices; } } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/WifiRoleAllowListTest.php ``` Expected: FAIL — the `role` match returns false (no such branch) and `default` is still accepted. - [ ] **Step 3: Add `role`, remove `default`** In `resolveMapping()`, replace the `match` block with: ```php $matched = match ($mapping->match_type) { 'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value), 'group' => $user->groups?->contains('name', $mapping->match_value) ?? false, 'ou' => str_contains($user->ou ?? '', $mapping->match_value), /* * Roles are the platform's own authority and need no external * sync to be correct, which is why quotas key off them. * * `default` is deliberately absent. It matched everybody, so its * existence made this an advisory list rather than an allow-list. * Returning null here is already the deny path: captiveCallback * renders "No WiFi access configured for your account." */ 'role' => $user->hasRole($mapping->match_value), default => false, }; ``` In **both** `storeMapping()` and `updateMapping()`, change the match_type rule: ```php 'match_type' => 'required|in:ou,group,email_domain,role', ``` - [ ] **Step 4: Run it and confirm it passes** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/WifiRoleAllowListTest.php ``` Expected: PASS, 5 tests. - [ ] **Step 5: Commit** ```bash git -C /project/central-unifi commit -m "Wi-Fi access becomes an explicit per-role allow-list" -- src/Http/Controllers/PortalController.php git -C /project/dashboard-shell add tests/Feature/Unifi/WifiRoleAllowListTest.php git -C /project/dashboard-shell commit -m "Wi-Fi access becomes an explicit per-role allow-list" -- tests/Feature/Unifi/WifiRoleAllowListTest.php ``` --- ## Task 4: Prove the device belongs to the person **Files:** - Create: `/project/central-unifi/src/Support/MacAddress.php` - Create: `/project/central-unifi/src/Services/DeviceResolver.php` - Modify: `/project/central-unifi/src/Services/UnifiApiClient.php` (add `findClientByIp`) - Modify: `/project/central-unifi/src/Http/Controllers/PortalController.php` (use the resolver instead of `?mac=`) - Test: `/project/dashboard-shell/tests/Feature/Unifi/DeviceResolverTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/DeviceResolverTest.php`: ```php and admit a * device they did not own, up to max_devices. The MAC is now derived from the * request's source IP via UniFi's own client table, and the client must be * associated to the AP that issued the redirect. * * Every failure mode refuses. That is deliberate, and it is also the risk: if * TrustProxies is wrong behind Traefik, every request resolves to the proxy and * NOBODY gets on the Wi-Fi. So "this IP is the proxy" is a distinct, reportable * outcome rather than just another miss. */ class DeviceResolverTest extends TestCase { private function resolver(array $client = null): DeviceResolver { $mock = \Mockery::mock(UnifiApiClient::class); $mock->shouldReceive('findClientByIp')->andReturn($client); $this->app->instance(UnifiApiClient::class, $mock); return app(DeviceResolver::class); } private function request(array $server = [], array $headers = []): Request { $r = Request::create('/portal/wifi/callback', 'GET', [], [], [], array_merge(['REMOTE_ADDR' => '10.20.0.9'], $server)); foreach ($headers as $k => $v) { $r->headers->set($k, $v); } return $r; } public function test_it_derives_the_mac_from_the_source_ip(): void { $result = $this->resolver([ 'mac' => '02:aa:bb:cc:dd:ee', 'ap_mac' => 'ac:8b:a9:00:00:01', 'ip' => '10.20.0.9', ])->resolve($this->request(), 'ac:8b:a9:00:00:01'); $this->assertTrue($result->ok); $this->assertSame('02:aa:bb:cc:dd:ee', $result->mac); } public function test_it_refuses_when_no_client_matches_the_ip(): void { $result = $this->resolver(null)->resolve($this->request(), 'ac:8b:a9:00:00:01'); $this->assertFalse($result->ok); $this->assertSame('no_client', $result->reason); } public function test_it_refuses_when_the_client_is_on_a_different_ap(): void { $result = $this->resolver([ 'mac' => '02:aa:bb:cc:dd:ee', 'ap_mac' => 'ac:8b:a9:00:00:99', 'ip' => '10.20.0.9', ])->resolve($this->request(), 'ac:8b:a9:00:00:01'); $this->assertFalse($result->ok); $this->assertSame('ap_mismatch', $result->reason); } /** * The outage case, reported distinctly. A forwarded header present while the * request IP still equals REMOTE_ADDR means TrustProxies is not applying. */ public function test_it_reports_an_untrusted_proxy_separately(): void { $result = $this->resolver(null)->resolve( $this->request([], ['X-Forwarded-For' => '10.20.0.9']), 'ac:8b:a9:00:00:01' ); $this->assertFalse($result->ok); $this->assertSame('proxy_untrusted', $result->reason); } /** * A device that has just associated has no IP yet, so it is briefly absent * from UniFi's table. Refusing on the first miss would tell a pupil their * device is not connected at the exact moment it is connecting. */ public function test_it_retries_before_giving_up_on_a_missing_client(): void { $mock = \Mockery::mock(UnifiApiClient::class); $mock->shouldReceive('findClientByIp')->times(3)->andReturn(null); $this->app->instance(UnifiApiClient::class, $mock); $result = app(DeviceResolver::class)->resolve($this->request(), 'ac:8b:a9:00:00:01'); $this->assertFalse($result->ok); $this->assertSame('no_client', $result->reason); } /** It must stop retrying the moment it succeeds. */ public function test_it_does_not_retry_once_it_has_the_client(): void { $mock = \Mockery::mock(UnifiApiClient::class); $mock->shouldReceive('findClientByIp')->once()->andReturn([ 'mac' => '02:aa:bb:cc:dd:ee', 'ap_mac' => 'ac:8b:a9:00:00:01', 'ip' => '10.20.0.9', ]); $this->app->instance(UnifiApiClient::class, $mock); $this->assertTrue(app(DeviceResolver::class)->resolve($this->request(), 'ac:8b:a9:00:00:01')->ok); } public function test_it_ignores_a_supplied_mac_entirely(): void { $request = $this->request(); $request->query->set('mac', '02:99:99:99:99:99'); // somebody else's device $result = $this->resolver([ 'mac' => '02:aa:bb:cc:dd:ee', 'ap_mac' => 'ac:8b:a9:00:00:01', 'ip' => '10.20.0.9', ])->resolve($request, 'ac:8b:a9:00:00:01'); $this->assertSame('02:aa:bb:cc:dd:ee', $result->mac, 'the derived MAC must win; a supplied one is never trusted'); $this->assertTrue($result->macParameterDisagreed, 'a disagreement is somebody authorizing a device they do not own, and must be visible'); } } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/DeviceResolverTest.php ``` Expected: FAIL — `Class "Dashboard\Unifi\Services\DeviceResolver" not found`. - [ ] **Step 3: Add the MAC helper** Create `/project/central-unifi/src/Support/MacAddress.php`: ```php getActiveClients() as $client) { if (($client['ip'] ?? null) === $ip) { return $client; } } return null; } ``` - [ ] **Step 5: Add the resolver** Create `/project/central-unifi/src/Services/DeviceResolver.php`: ```php ip(); /* * A device that has only just associated may have no IP yet, so UniFi's * client table does not know it. Retry briefly rather than telling a * pupil their device is not connected when it is about to be. * * Bounded on purpose: this runs inside a web request, and a longer wait * would hold a worker per connecting device at exactly the moment a * class of them arrives at once. */ $client = null; for ($attempt = 1; $attempt <= self::ATTEMPTS; $attempt++) { $client = $this->unifi->findClientByIp($ip); if ($client) { break; } if ($attempt < self::ATTEMPTS) { usleep(self::PAUSE_US); } } if (! $client) { // A forwarded header present while the request IP is still the raw // socket address means TrustProxies is not applying, so every lookup // here is doomed for the same reason. Say which failure this is. $forwarded = $request->headers->get('X-Forwarded-For'); $raw = $request->server('REMOTE_ADDR'); $reason = ($forwarded && $ip === $raw) ? 'proxy_untrusted' : 'no_client'; return new ResolvedDevice(ok: false, reason: $reason); } $mac = MacAddress::normalise($client['mac'] ?? null); if (! $mac) { return new ResolvedDevice(ok: false, reason: 'no_client'); } $clientAp = MacAddress::normalise($client['ap_mac'] ?? null); if (! $clientAp || $clientAp !== MacAddress::normalise($apMac)) { return new ResolvedDevice(ok: false, reason: 'ap_mismatch'); } $supplied = MacAddress::normalise($request->input('mac')); $disagreed = $supplied !== null && $supplied !== $mac; if ($disagreed) { Log::warning('unifi.portal: supplied MAC disagreed with the derived one', [ 'derived' => $mac, 'supplied' => $supplied, 'ip' => $ip, 'ap' => $apMac, ]); } return new ResolvedDevice( ok: true, mac: $mac, isRandomized: MacAddress::isRandomized($mac), macParameterDisagreed: $disagreed, client: $client, ); } } ``` - [ ] **Step 6: Run it and confirm it passes** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/DeviceResolverTest.php ``` Expected: PASS, 5 tests. - [ ] **Step 7: Use it in the controller** In `captiveCallback()`, delete the `$clientMac = strtolower($request->input('mac', ...))` line, the `if (! $clientMac)` guard, and the whole `preg_match` block with its security comment. Replace with: ```php $apMac = (string) $request->input('ap', ''); $ssid = (string) $request->input('ssid', ''); $device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac); if (! $device->ok) { return Inertia::render('Unifi/PortalDenied', [ 'reason' => match ($device->reason) { 'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.', 'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.', default => 'Still connecting — wait a moment and try again.', }, 'resolveFailure' => $device->reason, ]); } $clientMac = $device->mac; $isRandomized = $device->isRandomized; ``` - [ ] **Step 7b: Move the Task 2 test onto the derived MAC** `PortalQuotaCountsAuthorizationsTest` still passes `?mac=`, which is now ignored. In both tests, drop the `'mac' => '02:99:88:77:66:55',` query parameter, add `->withServerVariables(['REMOTE_ADDR' => '10.20.0.9'])` before `->get(...)`, and add this to each mock closure: ```php $mock->shouldReceive('findClientByIp')->andReturn([ 'mac' => '02:99:88:77:66:55', 'ap_mac' => 'ac:8b:a9:00:00:01', 'ip' => '10.20.0.9', ]); ``` The assertions do not change — the same device is identified a trustworthy way. - [ ] **Step 8: Run the whole Unifi suite** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ ``` Expected: everything PASSES. - [ ] **Step 9: Commit** ```bash git -C /project/central-unifi commit -m "The portal derives the device MAC instead of trusting the caller" -- src/Support/MacAddress.php src/Services/DeviceResolver.php src/Services/UnifiApiClient.php src/Http/Controllers/PortalController.php git -C /project/dashboard-shell add tests/Feature/Unifi/DeviceResolverTest.php git -C /project/dashboard-shell commit -m "The portal derives the device MAC instead of trusting the caller" -- tests/Feature/Unifi/DeviceResolverTest.php tests/Feature/Unifi/PortalQuotaCountsAuthorizationsTest.php ``` --- ## Task 5: Record whether the address was randomized **Files:** - Create: `/project/central-unifi/database/migrations/2026_08_21_000001_add_is_randomized_to_portal_sessions.php` - Modify: `/project/central-unifi/src/Models/PortalSession.php` (`$fillable`, `$casts`) - Modify: `/project/central-unifi/src/Http/Controllers/PortalController.php` (write it on the session) - Test: `/project/dashboard-shell/tests/Feature/Unifi/RandomizedMacIsRecordedTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/RandomizedMacIsRecordedTest.php`: ```php assertTrue(MacAddress::isRandomized('02:aa:bb:cc:dd:ee')); $this->assertTrue(MacAddress::isRandomized('a6:11:22:33:44:55')); } /** A real Ubiquiti prefix does not. */ public function test_a_manufacturer_address_is_not(): void { $this->assertFalse(MacAddress::isRandomized('ac:8b:a9:00:00:01')); $this->assertFalse(MacAddress::isRandomized('f0:9f:c2:11:22:33')); } public function test_it_normalises_before_judging(): void { $this->assertSame('02:aa:bb:cc:dd:ee', MacAddress::normalise('02-AA-BB-CC-DD-EE')); $this->assertNull(MacAddress::normalise('not a mac')); } } ``` - [ ] **Step 2: Run it and confirm it passes already** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/RandomizedMacIsRecordedTest.php ``` Expected: PASS — `MacAddress` landed in Task 4. This test exists to pin the bit arithmetic, which is the part nobody will remember. - [ ] **Step 3: Add the column** Create `/project/central-unifi/database/migrations/2026_08_21_000001_add_is_randomized_to_portal_sessions.php`: ```php boolean('is_randomized')->nullable()->after('mac_address'); }); } public function down(): void { Schema::table('unifi_portal_sessions', function (Blueprint $table) { $table->dropColumn('is_randomized'); }); } }; ``` - [ ] **Step 4: Store it** In `PortalSession.php` add `'is_randomized'` to `$fillable` and `'is_randomized' => 'boolean'` to `$casts`. In `PortalController::captiveCallback`, add to the `updateOrCreate` payload from Task 2: ```php 'is_randomized' => $isRandomized, ``` - [ ] **Step 5: Run the suite** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ ``` Expected: all PASS. - [ ] **Step 6: Commit** ```bash git -C /project/central-unifi commit -m "Record whether a portal session used a private Wi-Fi address" -- database/migrations/2026_08_21_000001_add_is_randomized_to_portal_sessions.php src/Models/PortalSession.php src/Http/Controllers/PortalController.php git -C /project/dashboard-shell add tests/Feature/Unifi/RandomizedMacIsRecordedTest.php git -C /project/dashboard-shell commit -m "Record whether a portal session used a private Wi-Fi address" -- tests/Feature/Unifi/RandomizedMacIsRecordedTest.php ``` --- ## Task 6: The reconciler **Files:** - Create: `/project/central-unifi/src/Console/ReconcilePortal.php` - Modify: `/project/central-unifi/src/Services/UnifiApiClient.php` (add `getAuthorizedGuestMacs`) - Modify: `/project/central-unifi/src/UnifiServiceProvider.php` (register the command) - Test: `/project/dashboard-shell/tests/Feature/Unifi/ReconcilePortalTest.php` - [ ] **Step 1: Write the failing test** Create `/project/dashboard-shell/tests/Feature/Unifi/ReconcilePortalTest.php`: ```php User::factory()->create(['is_active' => true])->id, 'mac_address' => $mac, 'is_active' => true, 'authorized_at' => now()->subHour(), 'expires_at' => now()->addDay(), ], $overrides)); } /** * One fake for the whole class. The command calls four methods on the client * and every test needs all four stubbed — doing that per test means adding a * line to four closures every time the command learns a new call, and * whichever one gets missed fails for a reason unrelated to what it tests. * * Returns the mock so a test can tighten an expectation on it. */ private function fakeUnifi(array $authorized = [], array $clients = []) { $mock = \Mockery::mock(UnifiApiClient::class); $mock->shouldReceive('getAuthorizedGuestMacs')->andReturn($authorized); $mock->shouldReceive('getActiveClients')->andReturn($clients); $mock->shouldReceive('unauthorizeGuest')->andReturn([]); $mock->shouldReceive('authorizeGuest')->andReturn([]); $this->app->instance(UnifiApiClient::class, $mock); return $mock; } public function test_it_deactivates_rows_past_their_expiry(): void { $stale = $this->session('02:00:00:00:00:01', ['expires_at' => now()->subMinute()]); $this->fakeUnifi(); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); $this->assertFalse((bool) $stale->fresh()->is_active); } public function test_it_revokes_a_unifi_authorization_with_no_live_row(): void { $mock = $this->fakeUnifi(authorized: ['02:00:00:00:00:99']); $mock->shouldReceive('unauthorizeGuest')->once()->with('02:00:00:00:00:99')->andReturn([]); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); } /** * A controller upgrade or restore can lose an authorization we still consider * live. Re-asserting it means the user is not bounced to the portal for * something that was not their doing. */ public function test_it_reauthorizes_a_live_row_unifi_has_forgotten(): void { $this->session('02:00:00:00:00:07'); $mock = $this->fakeUnifi(authorized: []); $mock->shouldReceive('authorizeGuest')->once()->andReturn([]); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); } public function test_it_leaves_a_matching_live_row_alone(): void { $this->session('02:00:00:00:00:07'); $mock = \Mockery::mock(UnifiApiClient::class); $mock->shouldReceive('getAuthorizedGuestMacs')->andReturn(['02:00:00:00:00:07']); $mock->shouldReceive('getActiveClients')->andReturn([]); $mock->shouldNotReceive('unauthorizeGuest'); $mock->shouldNotReceive('authorizeGuest'); $this->app->instance(UnifiApiClient::class, $mock); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); } /** * Device type is REPORTED, not enforced. UniFi infers it from observed * traffic, so it is not available at the moment of authorization — the * reconciler is the first point at which there is anything to record. */ public function test_it_records_the_device_type_once_unifi_has_fingerprinted_it(): void { $session = $this->session('02:00:00:00:00:07'); $this->fakeUnifi( authorized: ['02:00:00:00:00:07'], clients: [[ 'mac' => '02:00:00:00:00:07', 'dev_cat' => 'Smartphone', 'os_name' => 'iOS', 'hostname' => 'Alices-iPhone', ]], ); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); $session->refresh(); $this->assertSame('Smartphone', $session->device_type); $this->assertSame('iOS', $session->device_os); $this->assertSame('Alices-iPhone', $session->device_hostname); } /** An unfingerprinted client must not blank what we already knew. */ public function test_it_does_not_overwrite_a_known_type_with_nothing(): void { $session = $this->session('02:00:00:00:00:07', ['device_type' => 'Laptop']); $this->fakeUnifi( authorized: ['02:00:00:00:00:07'], clients: [['mac' => '02:00:00:00:00:07']], // no dev_cat yet ); $this->artisan('unifi:reconcile-portal')->assertExitCode(0); $this->assertSame('Laptop', $session->fresh()->device_type); } } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ReconcilePortalTest.php ``` Expected: FAIL — `The command "unifi:reconcile-portal" does not exist.` - [ ] **Step 3: Add the authorized-guest lookup** In `UnifiApiClient.php`, after `unauthorizeGuest()`: ```php /** * The MACs UniFi currently holds an active guest authorization for. * * Read from /stat/guest, which is the authorization list — deliberately not * /stat/sta, which is who is associated right now. Those are the two facts * this whole feature had been conflating. */ public function getAuthorizedGuestMacs(): array { $rows = $this->get('/stat/guest'); return collect($rows) ->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp) ->pluck('mac') ->map(fn ($m) => strtolower((string) $m)) ->values() ->all(); } ``` - [ ] **Step 4: Add the command** Create `/project/central-unifi/src/Console/ReconcilePortal.php`: ```php whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->update(['is_active' => false]); if ($expired) { $this->info("Expired {$expired} finished session(s)."); } try { $authorized = $unifi->getAuthorizedGuestMacs(); } catch (\Throwable $e) { // A controller we cannot reach is not evidence that anything should // change. Expiry above is local and already done. $this->error('Could not read UniFi authorizations: ' . $e->getMessage()); return self::SUCCESS; } $live = PortalSession::live()->get()->keyBy(fn ($s) => strtolower($s->mac_address)); // 2. Orphans: authorized on the network with no live row of ours. These // are what the old presence-based sweep produced — a device nobody // can see in the UI and nobody's quota is paying for. foreach (array_diff($authorized, $live->keys()->all()) as $orphan) { $unifi->unauthorizeGuest($orphan); $this->warn("Revoked orphan authorization {$orphan}."); } // 3. The other direction: we consider it live but UniFi has forgotten it // (upgrade, restore). Re-assert rather than making the person sign in // again for something that was not their doing. foreach ($live->keys()->diff($authorized) as $mac) { $session = $live->get($mac); $minutes = max(1, (int) now()->diffInMinutes($session->expires_at, false)); $unifi->authorizeGuest($mac, $minutes); $this->warn("Re-asserted authorization for {$mac} ({$minutes}m remaining)."); } $this->captureFingerprints($unifi, $live); return self::SUCCESS; } /** * Record what UniFi thinks each device IS. Reported only — nothing acts on * it, per the decision that enforcing on unvalidated fingerprint data would * lock out the wrong people. * * This is the earliest point the data exists: UniFi infers dev_cat from * observed traffic, so it is absent at the moment of authorization. * * @param \Illuminate\Support\Collection $live keyed by lower-case MAC */ private function captureFingerprints(UnifiApiClient $unifi, $live): void { try { $clients = collect($unifi->getActiveClients()) ->keyBy(fn ($c) => strtolower((string) ($c['mac'] ?? ''))); } catch (\Throwable $e) { $this->error('Could not read clients for fingerprints: ' . $e->getMessage()); return; } foreach ($live as $mac => $session) { $client = $clients->get($mac); if (! $client) { continue; } // Only ever fill in. An unfingerprinted client must not blank a type // we already recorded — that would lose the answer every time the // device happened to be quiet. $updates = array_filter([ 'device_type' => $client['dev_cat'] ?? null, 'device_os' => $client['os_name'] ?? null, 'device_hostname' => $client['hostname'] ?? null, ], fn ($v) => $v !== null && $v !== ''); if ($updates) { $session->update($updates); } } } } ``` - [ ] **Step 5: Register it** In `/project/central-unifi/src/UnifiServiceProvider.php`, add `ReconcilePortal::class` to the existing `$this->commands([...])` array (alongside `CaptureSnapshots::class`). Import it at the top. - [ ] **Step 6: Run it and confirm it passes** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ReconcilePortalTest.php ``` Expected: PASS, 4 tests. - [ ] **Step 7: Run the whole Unifi suite and the full suite once** ```bash bin/agentdb test "$DB" tests/Feature/Unifi/ bin/agentdb test "$DB" ``` Expected: Unifi all PASS. Full suite: only `Tests\Feature\Logs\ScriptRunsTest` fails — that is a known pre-existing failure, unrelated. Anything else is yours. - [ ] **Step 8: Bump the version and commit** ```bash # patch bump central-unifi/composer.json (1.13.4 -> 1.13.5) by hand, then: git -C /project/central-unifi commit -m "Reconcile Wi-Fi sessions against UniFi authorizations" -- src/Console/ReconcilePortal.php src/Services/UnifiApiClient.php src/UnifiServiceProvider.php composer.json git -C /project/dashboard-shell add tests/Feature/Unifi/ReconcilePortalTest.php git -C /project/dashboard-shell commit -m "Reconcile Wi-Fi sessions against UniFi authorizations" -- tests/Feature/Unifi/ReconcilePortalTest.php ``` - [ ] **Step 9: Release the database slot** ```bash bin/agentdb release "$DB" ``` --- ## Do not do these - **Do not schedule the reconciler yet.** It calls `unauthorizeGuest` against the live controller, and nothing here has been checked against real hardware. Scheduling belongs with the supervised verification session in the spec's section F, not in this plan. - **Do not register the `unifi` app.** Every route here still 404s because there is no `dashboard_apps` row, and whether that should change is an open question for the operator, not a step in this plan. - **Do not touch `disconnectSession`.** It already does `kickClient` + `unauthorizeGuest` and authorises owner-or-`unifi.auth` correctly. - **Do not remove `email_domain`, `group` or `ou`.** Only `default` is a catch-all; the others are narrow matches and are still useful.