CluPilotCloud/tests/Feature/Admin/ConsoleNetworkTest.php

169 lines
6.7 KiB
PHP

<?php
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Support\Settings;
use Livewire\Livewire;
/**
* Who may reach the console, by network address.
*
* The hostname guard answers under which NAME the console responds, and the
* caller picks the Host header — so it can never answer who is asking. This
* can, and the owner keeps the list themselves.
*/
/** Livewire's test harness has no withServerVariables(); set it on the request. */
function fromIp(string $ip): void
{
request()->server->set('REMOTE_ADDR', $ip);
}
beforeEach(function () {
config()->set('admin_access.hosts', []);
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
Settings::set('console.network_restricted', false);
Settings::set('console.allowed_ips', []);
});
it('changes nothing until the owner switches it on', function () {
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
->get('/admin')
->assertOk();
});
it('lets the management VPN in and turns everyone else away', function () {
Settings::set('console.network_restricted', true);
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '10.66.0.5'])
->get('/admin')
->assertOk();
// 404, never 403: a stranger must not learn a console lives here.
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
->get('/admin')
->assertNotFound();
});
it('lets in an address the owner has listed, without the VPN', function () {
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', ['203.0.113.0/24']);
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
->get('/admin')
->assertOk();
// A neighbouring range is still nobody.
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '198.51.100.4'])
->get('/admin')
->assertNotFound();
});
it('knows when a change would lock the owner out', function () {
// The one check whose failure mode is that nobody can reach the page that
// would fix it — so it is decided without a request, and tested that way.
expect(RestrictConsoleNetwork::wouldStillAllow('10.66.0.5', []))->toBeTrue()
->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', []))->toBeFalse()
->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', ['198.51.100.0/24']))->toBeTrue()
// Removing the entry you are sitting behind leaves you outside.
->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.9', []))->toBeFalse();
});
it('rejects an entry that is not an address or a range', function () {
// A typo that silently matches nothing is how someone locks themselves out
// while believing they have not.
foreach (['nonsense', '203.0.113.', '203.0.113.9/99', ''] as $bad) {
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->set('consoleIp', $bad)
->call('addConsoleIp')
->assertHasErrors('consoleIp');
}
expect(Settings::get('console.allowed_ips'))->toBe([]);
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->set('consoleIp', '203.0.113.0/24')
->call('addConsoleIp')
->assertHasNoErrors();
expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.0/24']);
});
it('needs the capability, not merely an operator account', function () {
Livewire::actingAs(operator('Support'))
->test(AdminSettings::class)
->call('addConsoleIp')
->assertForbidden();
});
it('is re-checked on every Livewire action, not only on the page', function () {
// The page is not where the actions run.
$persistent = app(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class)
->getPersistentMiddleware();
expect($persistent)->toContain(RestrictConsoleNetwork::class);
});
it('offers a way back in that does not depend on the console', function () {
// Every gate needs a door that does not depend on itself: the list is
// managed from the console, and the address you manage it from can change.
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', []);
$this->artisan('clupilot:console-access allow 203.0.113.42')->assertSuccessful();
expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.42'])
->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.42', ['203.0.113.42']))->toBeTrue();
$this->artisan('clupilot:console-access deny 203.0.113.42')->assertSuccessful();
expect(Settings::get('console.allowed_ips'))->toBe([]);
// And the restriction itself can be lifted from the shell — the only place
// that is reachable when the gate has shut on its own owner.
$this->artisan('clupilot:console-access open')->assertSuccessful();
expect(RestrictConsoleNetwork::isRestricted())->toBeFalse();
});
it('tells the reverse proxy the same thing it tells itself', function () {
// The proxy has its own allowlist and it runs FIRST. If the two disagree,
// everything the owner changes in the console is silently ineffective —
// and when their address changes they are turned away before reaching the
// page that would have fixed it.
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', ['203.0.113.7']);
// One assertion on the whole line: the ranges have to arrive together, in
// the matcher, not merely appear somewhere in the output.
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7')
->assertSuccessful();
});
it('opens the proxy too when the restriction is lifted', function () {
// Otherwise "open" in the console changes nothing a visitor can feel: the
// proxy keeps rejecting them before the application is reached.
Settings::set('console.network_restricted', false);
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('0.0.0.0/0')
->assertSuccessful();
});
it('never emits a matcher that lets nobody in', function () {
// An empty remote_ip matcher matches NOTHING in Caddy — the console would
// be unreachable from everywhere, including from where it gets fixed.
config()->set('admin_access.trusted_ranges', []);
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', []);
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('127.0.0.1')
->assertSuccessful();
});