Files
ServerKit/backend/tests/test_security_suite_extraction.py
Juan DenisandClaude Fable 5 9f92850cbd feat(security)!: extract the install-gated security suite to extensions (plan 47 Ph3b-5 / plan 55 Ph3)
The 1791-line security omnibus splits along tool lines and the install-gated
surfaces leave core for five standalone extensions - serverkit-clamav
(ClamAV+YARA scanning, quarantine, scan jobs), serverkit-fail2ban
(management UI; probes stay core on Fail2banJailService), serverkit-lynis,
serverkit-auto-updates, and serverkit-image-scan (headless CVE/SBOM
scanning; models stay core per the plan 52 D1 data seam). Each mounts its
routes back at the same /api/v1/security prefix when installed, so route
shapes never change; absent extensions mean plain 404s, no dead UI.

Core keeps the zero-host-package baseline: status/config, legacy integrity,
suspicious activity/events, SSH keys, IP lists, the audit (fail2ban check
now probes via the jail service; updates check was already self-contained),
scoped FIM, and the scan/alert logs + notification helpers extensions write
through. Job handlers for security.malware_scan/lynis_scan/image_scan now
come exclusively from the extensions' manifest jobs key.

Security page becomes core-tabs-only; extension tabs contribute into the
security tab group (3a groupId). Overview posture drops the hardcoded
ClamAV rows and offers the Marketplace when no security extension is
installed. Upgrade parity: run_registry_auto_install() installs each
extension from the registry once, gated on the host tool actually being
present, fail-soft until the registry entry is published.

Wizard round 2 (Ph5): a security-posture chooser (minimal/recommended/
hardened) on the Summary step, catalog-resolved so unpublished slugs never
render dead installs; persisted as onboarding_security_posture.

Moved test suites re-home to the extension repos; panel keeps
test_security_suite_extraction.py (18 proving tests: absent-shape 404s,
lean summary, jail-service probes, migration gates, posture wiring).
docs/API_SURFACE.md regenerated (34 operations moved out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 13:53:04 -04:00

250 lines
11 KiB
Python

"""Prove the security-suite split (plan 47 Ph3b-4 / plan 55 Phase 3).
The install-gated security tools left core for standalone extensions
(serverkit-clamav, serverkit-fail2ban, serverkit-lynis, serverkit-auto-updates,
serverkit-image-scan). A core panel keeps only the zero-host-package baseline:
status/config, legacy integrity, suspicious activity, events, SSH keys, IP
lists, the audit and scoped FIM. These tests pin the absent-extension shape —
the moved prefixes 404 (blueprint never registered, so routing wins before
auth), the core surfaces still answer, and the probes core kept (fail2ban
status on Fail2banJailService) still drive Setup Health and the audit.
"""
import pytest
from app.services.security_service import SecurityService
from app.services.fail2ban_jail_service import Fail2banJailService
# Route paths that moved to security extensions, one representative per
# extension surface. On a lean panel every one of them is unrouted (404).
MOVED_ROUTE_PREFIXES = [
'/api/v1/security/clamav/status', # serverkit-clamav
'/api/v1/security/scan/status', # serverkit-clamav
'/api/v1/security/quarantine', # serverkit-clamav
'/api/v1/security/yara/rules', # serverkit-clamav
'/api/v1/security/fail2ban/status', # serverkit-fail2ban
'/api/v1/security/lynis/status', # serverkit-lynis
'/api/v1/security/auto-updates/status', # serverkit-auto-updates
'/api/v1/security/image-scans/install', # serverkit-image-scan
'/api/v1/security/sboms/1', # serverkit-image-scan
]
def test_moved_security_routes_are_unrouted(app):
rules = {r.rule for r in app.url_map.iter_rules()}
for path in MOVED_ROUTE_PREFIXES:
static_prefix = path.replace('/1', '')
offenders = [r for r in rules if r.startswith(static_prefix)]
assert not offenders, f'{path}: still routed in core: {offenders}'
def test_moved_route_404s_before_auth(client):
"""No blueprint => plain 404, no auth prompt, no 500."""
for path in MOVED_ROUTE_PREFIXES:
resp = client.get(path)
assert resp.status_code == 404, f'{path} -> {resp.status_code}'
def test_core_security_routes_survive(app):
rules = {r.rule for r in app.url_map.iter_rules()}
for path in [
'/api/v1/security/status',
'/api/v1/security/config',
'/api/v1/security/audit',
'/api/v1/security/events',
'/api/v1/security/failed-logins',
'/api/v1/security/ssh-keys',
'/api/v1/security/ip-lists',
'/api/v1/security/integrity/check',
'/api/v1/security/fim',
]:
assert path in rules, f'{path} missing from the lean core surface'
def test_security_summary_is_core_only(app):
"""The summary no longer reaches for scanner state the core doesn't own."""
with app.app_context():
summary = SecurityService.get_security_summary()
assert 'clamav' not in summary
assert 'scan_status' not in summary
# Core-owned fields stay.
assert 'file_integrity' in summary
assert 'recent_alerts' in summary
assert 'notifications_enabled' in summary
def test_security_service_shed_the_moved_buckets():
"""The moved tool methods are gone from the core service (no zombies)."""
for gone in [
'get_clamav_status', 'install_clamav', 'scan_directory',
'quarantine_file', 'get_fail2ban_status', 'install_fail2ban',
'ban_ip', 'get_lynis_status', 'run_lynis_scan',
'get_auto_updates_status', 'register_jobs',
]:
assert not hasattr(SecurityService, gone), f'SecurityService.{gone} still exists'
def test_jail_service_owns_the_fail2ban_probes():
"""Core seam: probes + unban live on the jail write-half service."""
for kept in ['get_fail2ban_status', 'get_fail2ban_jail_status', 'unban_ip']:
assert hasattr(Fail2banJailService, kept), f'Fail2banJailService.{kept} missing'
def test_moved_services_left_the_tree():
for module in [
'app.services.malware_scan_service',
'app.services.yara_scan_service',
'app.services.image_scanner_service',
]:
with pytest.raises(ImportError):
__import__(module)
def test_no_core_registration_of_extension_job_kinds(app):
"""The three scanner job kinds register via the extensions' manifest
`jobs` key — a lean core must not have them (a queued job on a panel
whose extension was removed fails loudly instead of running stale code).
"""
from app.jobs import registry
for kind in ('security.malware_scan', 'security.lynis_scan', 'security.image_scan'):
assert not registry.is_registered(kind), f'{kind} registered in lean core'
def test_image_scan_models_stay_core():
"""Core data seam (plan 52 D1): the extension reads/writes core tables."""
from app.models import ImageVulnerabilityScan, SbomArtifact
assert ImageVulnerabilityScan.__tablename__ == 'image_vulnerability_scans'
assert SbomArtifact.__tablename__ == 'sbom_artifacts'
# ---------------------------------------------------------------------------
# Upgrade parity: run_registry_auto_install (the GATED_BUILTIN_SLUGS analogue
# for registry-only security extensions).
# ---------------------------------------------------------------------------
def _reset_registry_marker(app):
from app.services.settings_service import SettingsService
with app.app_context():
SettingsService.set('extensions.registry_auto_installed_slugs', '')
def test_registry_auto_install_defers_until_entry_published(app, monkeypatch):
"""Host tool present + entry unpublished => no burn, retried next boot."""
from app.services import extension_migration as em
_reset_registry_marker(app)
monkeypatch.setattr(em, '_looks_like_existing_install', lambda: True)
monkeypatch.setattr(em, 'REGISTRY_GATED_SLUGS',
{'serverkit-fail2ban': lambda: True})
from app.services import registry_service
monkeypatch.setattr(registry_service, 'get_entry', lambda slug: None)
with app.app_context():
em.run_registry_auto_install()
assert 'serverkit-fail2ban' not in em._registry_processed_slugs()
def test_registry_auto_install_installs_once_when_published(app, monkeypatch):
from app.services import extension_migration as em
_reset_registry_marker(app)
monkeypatch.setattr(em, '_looks_like_existing_install', lambda: True)
monkeypatch.setattr(em, 'REGISTRY_GATED_SLUGS',
{'serverkit-fail2ban': lambda: True})
from app.services import registry_service
monkeypatch.setattr(registry_service, 'get_entry',
lambda slug: {'slug': slug, 'source': 'https://x/z.zip'})
calls = []
from app.services import plugin_service
monkeypatch.setattr(plugin_service, 'install_registry_extension',
lambda slug, user_id=None: calls.append(slug))
with app.app_context():
em.run_registry_auto_install()
assert calls == ['serverkit-fail2ban']
assert 'serverkit-fail2ban' in em._registry_processed_slugs()
# Second run: marked, nothing reinstalls.
em.run_registry_auto_install()
assert calls == ['serverkit-fail2ban']
def test_registry_auto_install_skips_fresh_installs(app, monkeypatch):
from app.services import extension_migration as em
_reset_registry_marker(app)
monkeypatch.setattr(em, '_looks_like_existing_install', lambda: False)
monkeypatch.setattr(em, 'REGISTRY_GATED_SLUGS',
{'serverkit-clamav': lambda: True})
with app.app_context():
em.run_registry_auto_install()
# Marked done without installing — fresh installs use the wizard.
assert 'serverkit-clamav' in em._registry_processed_slugs()
def test_registry_auto_install_tool_absent_marks_done(app, monkeypatch):
from app.services import extension_migration as em
_reset_registry_marker(app)
monkeypatch.setattr(em, '_looks_like_existing_install', lambda: True)
monkeypatch.setattr(em, 'REGISTRY_GATED_SLUGS',
{'serverkit-lynis': lambda: False})
with app.app_context():
em.run_registry_auto_install()
assert 'serverkit-lynis' in em._registry_processed_slugs()
# ---------------------------------------------------------------------------
# Wizard round 2 (plan 47 Ph5): security posture.
# ---------------------------------------------------------------------------
def test_security_posture_levels_shape():
from app.services.plugin_service import SECURITY_POSTURE_LEVELS
assert set(SECURITY_POSTURE_LEVELS) == {'minimal', 'recommended', 'hardened'}
assert SECURITY_POSTURE_LEVELS['minimal'] == []
# hardened is a superset of recommended
assert set(SECURITY_POSTURE_LEVELS['recommended']) <= set(
SECURITY_POSTURE_LEVELS['hardened'])
def test_recommend_security_extensions_drops_unpublished(app):
"""With the bundled index (no security extensions published yet) every
level resolves to only what the catalog can install — no dead buttons."""
from app.services.plugin_service import (
SECURITY_POSTURE_LEVELS, recommend_security_extensions)
with app.app_context():
for level in SECURITY_POSTURE_LEVELS:
resolved = recommend_security_extensions(level)
slugs = {e['slug'] for e in resolved}
assert slugs <= set(SECURITY_POSTURE_LEVELS[level])
for entry in resolved:
assert entry.get('display_name')
def test_recommendations_endpoint_carries_postures(client, auth_headers):
resp = client.get('/api/v1/plugins/recommendations', headers=auth_headers)
assert resp.status_code == 200
body = resp.get_json()
assert set(body.get('security_postures', {})) == {
'minimal', 'recommended', 'hardened'}
def test_complete_onboarding_persists_posture(client, auth_headers, app):
resp = client.post('/api/v1/auth/complete-onboarding', headers=auth_headers,
json={'use_cases': [], 'security_posture': 'hardened'})
assert resp.status_code == 200
from app.services.settings_service import SettingsService
with app.app_context():
assert SettingsService.get('onboarding_security_posture') == 'hardened'
def test_complete_onboarding_invalid_posture_falls_back(client, auth_headers, app):
resp = client.post('/api/v1/auth/complete-onboarding', headers=auth_headers,
json={'use_cases': [], 'security_posture': 'yolo'})
assert resp.status_code == 200
from app.services.settings_service import SettingsService
with app.app_context():
assert SettingsService.get('onboarding_security_posture') == 'minimal'