"""Tests for the scoped file-integrity monitoring service + API (task #16).""" import json import os import stat as stat_mod import pytest from app.services.file_integrity_service import ( FIM_EVENT_KEY, FIM_JOB_KIND, FileIntegrityScopeError, FileIntegrityService, OPTIN_SETTING_KEY, ) # --------------------------------------------------------------------------- # # Helpers / fixtures # --------------------------------------------------------------------------- # @pytest.fixture def fim(tmp_path, monkeypatch): """FileIntegrityService pointed at tmp dirs for state + nginx/systemd roots.""" state_dir = tmp_path / 'state' sites = tmp_path / 'sites-enabled' confd = tmp_path / 'conf.d' systemd = tmp_path / 'systemd' for d in (sites, confd, systemd): d.mkdir() monkeypatch.setattr(FileIntegrityService, 'STATE_DIR', str(state_dir)) monkeypatch.setattr(FileIntegrityService, 'NGINX_ROOTS', [str(sites), str(confd)]) monkeypatch.setattr(FileIntegrityService, 'SYSTEMD_ROOT', str(systemd)) (sites / 'site-a.conf').write_text('server { listen 80; }') (confd / 'gzip.conf').write_text('gzip on;') (systemd / 'serverkit-agent.service').write_text('[Unit]\nDescription=x\n') (systemd / 'unrelated.service').write_text('[Unit]\nDescription=y\n') return { 'sites': sites, 'confd': confd, 'systemd': systemd, 'state': state_dir, } def _make_app(db, name='fimapp', root_path=None): from app.models import User from app.models.application import Application user = User.query.first() if user is None: from werkzeug.security import generate_password_hash user = User( email='fim@test.local', username='fimuser', password_hash=generate_password_hash('x'), role=User.ROLE_ADMIN, is_active=True, ) db.session.add(user) db.session.commit() application = Application( name=name, app_type='php', user_id=user.id, root_path=root_path, ) db.session.add(application) db.session.commit() return application # --------------------------------------------------------------------------- # # Baseline + check diffing # --------------------------------------------------------------------------- # def test_baseline_records_files(fim): summary = FileIntegrityService.baseline('nginx') assert summary['scope'] == 'nginx' assert summary['file_count'] == 2 # Multi-root scope prefixes relpaths with the root basename. state = FileIntegrityService._load_state('nginx') keys = set(state['files']) assert 'sites-enabled/site-a.conf' in keys assert 'conf.d/gzip.conf' in keys entry = state['files']['sites-enabled/site-a.conf'] assert set(entry) == {'sha256', 'size', 'mode', 'mtime'} assert len(entry['sha256']) == 64 def test_check_detects_added_removed_modified(fim): FileIntegrityService.baseline('nginx') (fim['sites'] / 'site-b.conf').write_text('server { listen 81; }') # added (fim['confd'] / 'gzip.conf').unlink() # removed (fim['sites'] / 'site-a.conf').write_text('server { listen 443; }') # modified result = FileIntegrityService.check('nginx') assert result['added'] == ['sites-enabled/site-b.conf'] assert result['removed'] == ['conf.d/gzip.conf'] assert len(result['modified']) == 1 mod = result['modified'][0] assert mod['path'] == 'sites-enabled/site-a.conf' assert 'hash' in mod['what'] assert 'size' in mod['what'] # content length changed too assert result['total_changes'] == 3 assert result['counts'] == {'added': 1, 'removed': 1, 'modified': 1} # Last result is persisted in the scope state. state = FileIntegrityService._load_state('nginx') assert state['last_check']['total_changes'] == 3 def test_check_detects_mode_change(fim): target = fim['sites'] / 'site-a.conf' FileIntegrityService.baseline('nginx') before = os.stat(target).st_mode os.chmod(target, stat_mod.S_IREAD) # read-only works on every OS if os.stat(target).st_mode == before: os.chmod(target, before) pytest.skip('chmod does not change st_mode on this filesystem') try: result = FileIntegrityService.check('nginx') mods = {m['path']: m['what'] for m in result['modified']} assert mods == {'sites-enabled/site-a.conf': ['mode']} finally: os.chmod(target, before) def test_check_without_baseline_raises(fim): with pytest.raises(FileIntegrityScopeError): FileIntegrityService.check('nginx') def test_unknown_scope_rejected(fim): with pytest.raises(FileIntegrityScopeError): FileIntegrityService.baseline('etc-passwd') with pytest.raises(FileIntegrityScopeError): FileIntegrityService.check('app:notanumber') def test_systemd_scope_only_serverkit_units(fim): summary = FileIntegrityService.baseline('systemd') state = FileIntegrityService._load_state('systemd') assert summary['file_count'] == 1 assert list(state['files']) == ['serverkit-agent.service'] # A change to a non-serverkit unit stays invisible. (fim['systemd'] / 'unrelated.service').write_text('changed') result = FileIntegrityService.check('systemd') assert result['total_changes'] == 0 def test_accept_rebaselines(fim): FileIntegrityService.baseline('nginx') (fim['sites'] / 'site-b.conf').write_text('new') assert FileIntegrityService.check('nginx')['total_changes'] == 1 FileIntegrityService.accept('nginx') assert FileIntegrityService.check('nginx')['total_changes'] == 0 def test_accept_preserves_extra_excludes(fim): FileIntegrityService.baseline('nginx', options={'exclude': ['*.ignore']}) (fim['sites'] / 'x.ignore').write_text('noise') assert FileIntegrityService.check('nginx')['total_changes'] == 0 FileIntegrityService.accept('nginx') (fim['sites'] / 'y.ignore').write_text('more noise') assert FileIntegrityService.check('nginx')['total_changes'] == 0 # --------------------------------------------------------------------------- # # Exclusions # --------------------------------------------------------------------------- # def test_nginx_scope_excludes_logs(fim): (fim['sites'] / 'access.log').write_text('GET /') FileIntegrityService.baseline('nginx') state = FileIntegrityService._load_state('nginx') assert 'sites-enabled/access.log' not in state['files'] (fim['sites'] / 'error.log').write_text('boom') assert FileIntegrityService.check('nginx')['total_changes'] == 0 def test_app_scope_excludes_uploads_and_cache(fim, tmp_path, app, db_session): docroot = tmp_path / 'docroot' (docroot / 'wp-content' / 'uploads').mkdir(parents=True) (docroot / 'wp-content' / 'cache').mkdir(parents=True) (docroot / 'index.php').write_text('