import io import os import re import struct import sys import tempfile import types import unittest import warnings import zlib from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch from PIL import Image sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import main from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url from image_optimizer import create_optimized_candidate from image_safety import safe_image_open class FakeCursor: def __init__(self, conn): self.conn = conn self.results = [] self.index = 0 self.rowcount = 0 def __enter__(self): return self def __exit__(self, *_): return False def close(self): pass def execute(self, sql, params=None): params = params or () self.index = 0 self.rowcount = 0 self.results = [] if sql == 'SHOW TABLES': self.results = [{'Tables_in_test': table} for table in self.conn.schemas] elif sql.startswith('SHOW COLUMNS FROM'): table = re.search(r'`([^`]+)`', sql).group(1) self.results = [{'Field': name, 'Type': data_type} for name, data_type in self.conn.schemas[table].items()] elif sql.startswith('SELECT fid, filepath, filesize FROM'): self.results = [dict(row) for row in self.conn.files] elif sql.startswith('SELECT filesize, filepath FROM'): self.results = [dict(row) for row in self.conn.files if row['fid'] == params[0]] elif sql.startswith('UPDATE `files` SET filesize'): if self.conn.fail_updates: self.conn.fail_updates -= 1 raise RuntimeError('Simulated DB error') size, fid, path = params for row in self.conn.files: if row['fid'] == fid and row['filepath'] == path: if row['filesize'] != size: row['filesize'] = size self.rowcount = 1 break return elif 'FROM `upload` t JOIN' in sql or 'FROM `content_field_scan` t JOIN' in sql: table = 'upload' if 'FROM `upload`' in sql else 'content_field_scan' field = 'fid' if table == 'upload' else 'field_scan_fid' for entry in self.conn.relations.get(table, []): node = self.conn.nodes.get(entry['nid']) if not node or (entry.get('vid') and node['vid'] != entry['vid']): continue if entry[field] in params: self.results.append({ 'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], 'node_status': node['status'], 'fid': entry[field], 'cid': entry.get('cid'), }) elif 'FROM `comment_upload` t' in sql: for entry in self.conn.relations.get('comment_upload', []): comment = next(c for c in self.conn.comments if c['cid'] == entry['cid']) node = self.conn.nodes[comment['nid']] if entry['fid'] in params: self.results.append({'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], 'node_status': node['status'], 'comment_status': comment['status'], 'cid': comment['cid'], 'fid': entry['fid']}) elif 'r.body AS body' in sql: for node in self.conn.nodes.values(): for revision in self.conn.revisions: if revision['nid'] == node['nid'] and revision['vid'] == node['vid']: self.results.append({ 'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], 'node_status': node['status'], 'body': revision['body'], }) elif 'c.cid, c.status AS comment_status' in sql: for comment in self.conn.comments: node = self.conn.nodes[comment['nid']] self.results.append({ 'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], 'node_status': node['status'], 'cid': comment['cid'], 'comment_status': comment['status'], 'comment': comment['comment'], }) else: raise AssertionError(f'Unexpected fake SQL: {sql} / {params}') self.rowcount = len(self.results) def fetchall(self): return list(self.results) def fetchone(self): return self.results[0] if self.results else None def fetchmany(self, count): result = self.results[self.index:self.index + count] self.index += len(result) return result class FakeConnection: def __init__(self): self.schemas = { 'files': {'fid': 'int', 'filepath': 'varchar(255)', 'filesize': 'int'}, 'node': {'nid': 'int', 'vid': 'int', 'title': 'varchar(255)', 'type': 'varchar(32)', 'status': 'tinyint'}, 'node_revisions': {'nid': 'int', 'vid': 'int', 'body': 'longtext'}, 'upload': {'fid': 'int', 'nid': 'int', 'vid': 'int'}, 'content_field_scan': {'field_scan_fid': 'int', 'nid': 'int', 'vid': 'int'}, 'comments': {'cid': 'int', 'nid': 'int', 'comment': 'longtext', 'status': 'tinyint'}, 'comment_upload': {'cid': 'int', 'fid': 'int'}, } self.nodes = { 11: {'nid': 11, 'vid': 101, 'title': 'Stary skan mapy', 'type': 'forum', 'status': 1}, 12: {'nid': 12, 'vid': 102, 'title': 'Zdjecia z wakacji', 'type': 'story', 'status': 1}, 13: {'nid': 13, 'vid': 103, 'title': 'Odpowiedz na forum', 'type': 'forum', 'status': 0}, } self.relations = { 'upload': [{'nid': 11, 'vid': 101, 'fid': 7}, {'nid': 13, 'vid': 999, 'fid': 7}], 'content_field_scan': [{'nid': 12, 'vid': 102, 'field_scan_fid': 8}], 'comment_upload': [{'cid': 55, 'fid': 8}], } self.revisions = [ {'nid': 11, 'vid': 101, 'body': ''}, {'nid': 12, 'vid': 102, 'body': '

Wspomnienia.

'}, {'nid': 13, 'vid': 103, 'body': 'Zobacz mapa.jpg tutaj.'}, {'nid': 13, 'vid': 999, 'body': 'NIEAKTUALNE /sites/default/files/scans/mapa.jpg'}, ] self.comments = [ {'cid': 55, 'nid': 12, 'comment': 'Znalazlem /sites/default/files/scans/mapa.jpg', 'status': 1}, ] self.files = [] self.fail_updates = 0 self.commits = 0 self.rollbacks = 0 def __enter__(self): return self def __exit__(self, *_): return False def cursor(self, *_): return FakeCursor(self) def commit(self): self.commits += 1 def rollback(self): self.rollbacks += 1 class TestLinks(unittest.TestCase): def test_links(self): self.assertEqual(node_url('https://example.org/forum/', 42), 'https://example.org/forum/node/42') self.assertEqual(node_url('https://example.org/forum', 42, 66), 'https://example.org/forum/node/42#comment-66') self.assertEqual(node_url('https://example.org/forum', 42, clean_urls=False), 'https://example.org/forum/?q=node/42') self.assertIn('skan%20mapa.jpg', search_url('https://example.org', 'skan mapa.jpg')) self.assertEqual(node_url('https://example.org', 42, 66, comment_permalinks=True), 'https://example.org/comment/66#comment-66') def test_matcher_precision(self): matcher = ReferenceMatcher([ {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}, ]) self.assertEqual(matcher.match('')[7], 'sciezka') self.assertEqual(matcher.match('mapa.jpg')[7], 'nazwa') self.assertFalse(matcher.match('supermapa.jpg')) self.assertFalse(ReferenceMatcher([ {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'} ], basename_match=False).match('mapa.jpg')) def test_schema_references_current_only_and_comment_status(self): conn = FakeConnection() lookup = DrupalReferenceLookup(conn, base_url='https://forum.example.org') files = [ {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}, {'fid': 8, 'db_filepath': 'sites/default/files/inne.jpg', 'rel_path': 'inne.jpg'}, ] fake_pymysql = types.SimpleNamespace(cursors=types.SimpleNamespace(SSDictCursor=object)) with patch.dict(sys.modules, {'pymysql': fake_pymysql}): with redirect_stdout(io.StringIO()): result = lookup.lookup(files) self.assertEqual({x['nid'] for x in result[7]}, {11, 12, 13}) self.assertEqual({x['nid'] for x in result[8]}, {12}) attached_to_comment = [x for x in result[8] if x['cid'] == 55][0] self.assertIn('comment_upload.fid', attached_to_comment['sources']) direct = [x for x in result[7] if x['nid'] == 11][0] self.assertIn('upload.fid', direct['sources']) self.assertIn('tresc wpisu (sciezka)', direct['sources']) self.assertEqual(direct['confidence'], 'pewne') self.assertTrue(direct['url'].endswith('/node/11')) comment = [x for x in result[7] if x['cid'] == 55][0] self.assertFalse(comment['comment_published']) self.assertEqual(comment['url'], 'https://forum.example.org/node/12#comment-55') possible = [x for x in result[7] if x['nid'] == 13][0] self.assertFalse(possible['published']) self.assertEqual(possible['confidence'], 'mozliwe') self.assertNotIn('upload.fid', possible['sources']) # nieaktualna rewizja class TestFiles(unittest.TestCase): def setUp(self): self.workdir = tempfile.TemporaryDirectory() self.addCleanup(self.workdir.cleanup) self.root = Path(self.workdir.name) self.site_root = self.root / 'drupal' self.folder = self.site_root / 'sites' / 'default' / 'files' (self.folder / 'scans').mkdir(parents=True) self.image = self.folder / 'scans' / 'mapa.jpg' src = Image.effect_noise((950, 1250), 115).convert('RGB') src.save(self.image, 'JPEG', quality=99) self.conn = FakeConnection() self.conn.files = [{ 'fid': 7, 'filepath': 'sites/default/files/scans/mapa.jpg', 'filesize': 12345, }] self.cfg = types.SimpleNamespace( FILES_BASE_PATH=str(self.folder), PUBLIC_FILES_PATH='sites/default/files', DRUPAL_ROOT=str(self.site_root), DRUPAL_BASE_URL='https://forum.example.org', ) def args(self, *overrides): return main.build_parser().parse_args(list(overrides)) def test_resolve_prevent_traversal(self): valid, rel = main.resolve_filepath('sites/default/files/scans/mapa.jpg', str(self.folder)) self.assertEqual(valid, str(self.image)) self.assertEqual(rel, 'scans/mapa.jpg') bad, _ = main.resolve_filepath('../../etc/shadow', str(self.folder)) self.assertIsNone(bad) outside, _ = main.resolve_filepath('/etc/passwd', str(self.folder)) self.assertIsNone(outside) def test_backup_must_not_be_in_webroot(self): with self.assertRaises(ValueError): main._safe_backup_dir(str(self.site_root / 'backup'), str(self.folder), str(self.site_root)) def test_dry_run_no_changes_and_preview(self): args = self.args('--optimize', '--update-db', '--dry-run', '--max-width', '600', '--max-height', '800', '--max-file-mb', '0.5') images, missing, prefix = main.collect_images(self.conn, self.cfg, args) self.assertFalse(missing) before = self.image.read_bytes() with redirect_stdout(io.StringIO()): stats = main.process_changes(self.conn, self.cfg, args, images, prefix) self.assertEqual(self.image.read_bytes(), before) self.assertEqual(self.conn.files[0]['filesize'], 12345) self.assertEqual(stats['optimized'], 0) self.assertEqual(stats['planned'], 1) self.assertEqual(images[0]['optimize_status'], 'planned') self.assertLess(images[0]['after_size'], images[0]['before_size']) def test_apply_backup_db_and_restore_on_sql_failure(self): args = self.args('--optimize', '--apply', '--max-width', '600', '--max-height', '800', '--max-file-mb', '0.5', '--backup-dir', str(self.root / 'backups')) before = self.image.read_bytes() images, _, prefix = main.collect_images(self.conn, self.cfg, args) with redirect_stdout(io.StringIO()): stats = main.process_changes(self.conn, self.cfg, args, images, prefix) self.assertEqual(stats['optimized'], 1) self.assertEqual(stats['errors'], 0) self.assertLess(self.image.stat().st_size, len(before)) self.assertEqual(self.conn.files[0]['filesize'], self.image.stat().st_size) self.assertEqual(Path(images[0]['backup_file']).read_bytes(), before) with Image.open(self.image) as img: self.assertLessEqual(img.width, 600) self.assertLessEqual(img.height, 800) # Ponownie uruchomienie z bladem w DB: plik i metadata wracaja. self.conn.fail_updates = 1 self.conn.files[0]['filesize'] = 12345 second_before = self.image.read_bytes() args2 = self.args('--optimize', '--apply', '--max-width', '400', '--max-height', '550', '--max-file-mb', '0.3', '--backup-dir', str(self.root / 'backups')) images2, _, prefix = main.collect_images(self.conn, self.cfg, args2) with redirect_stdout(io.StringIO()): stats2 = main.process_changes(self.conn, self.cfg, args2, images2, prefix) self.assertEqual(stats2['errors'], 1) self.assertEqual(self.image.read_bytes(), second_before) self.assertEqual(self.conn.files[0]['filesize'], 12345) def test_symlink_is_not_modified(self): link = self.folder / 'scans' / 'alias.jpg' link.symlink_to(self.image) self.conn.files[0]['filepath'] = 'sites/default/files/scans/alias.jpg' args = self.args('--optimize', '--dry-run', '--max-width', '500') images, _, prefix = main.collect_images(self.conn, self.cfg, args) self.assertEqual(images[0]['path'], str(link)) with redirect_stdout(io.StringIO()): stats = main.process_changes(self.conn, self.cfg, args, images, prefix) self.assertEqual(images[0]['optimize_status'], 'skip') self.assertEqual(stats['planned'], 0) self.assertTrue(link.is_symlink()) def test_exif_orientation_after_optimization(self): original = self.folder / 'scan-orientation.jpg' img = Image.new('RGB', (240, 120), 'orange') exif = Image.Exif() exif[274] = 6 img.save(original, 'JPEG', quality=100, exif=exif) optimized = create_optimized_candidate(str(original), max_width=150, max_height=200, max_bytes=100, min_gain_percent=0) self.assertEqual(optimized['status'], 'ready', optimized['reason']) try: with Image.open(optimized['temp_path']) as out: self.assertEqual(out.size, (100, 200)) self.assertNotEqual(out.getexif().get(274), 6) finally: os.unlink(optimized['temp_path']) def test_png_with_alpha_preserved(self): filename = self.folder / 'scans' / 'alpha.png' img = Image.new('RGBA', (300, 900), (10, 100, 50, 130)) img.save(filename) result = create_optimized_candidate(str(filename), max_width=200, max_height=500, max_bytes=200, min_gain_percent=0) self.assertEqual(result['status'], 'ready', result['reason']) try: with Image.open(result['temp_path']) as output: self.assertEqual(output.mode, 'RGBA') self.assertEqual(output.size, (167, 500)) self.assertEqual(output.getpixel((1, 1))[3], 130) finally: os.unlink(result['temp_path']) def test_no_optimization_for_small_or_gif(self): tiny = self.folder / 'tiny.png' Image.new('RGB', (30, 30), 'red').save(tiny) self.assertEqual(create_optimized_candidate(str(tiny))['status'], 'skip') gif = self.folder / 'animated.gif' Image.new('RGB', (300, 500), 'red').save(gif) self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip') def test_image_limit_replaces_pillow_warning_without_disabling_safety(self): # Symulujemy niski prog Pillow: nasz konfigurowalny limit nie # powinien powodowac ostrzezen ani wychodzic poza scope. with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000): with warnings.catch_warnings(record=True) as emitted: warnings.simplefilter('always', Image.DecompressionBombWarning) self.assertEqual(main.file_resolution(str(self.image), max_pixels=2_000_000), (950, 1250)) result = create_optimized_candidate( str(self.image), max_width=550, max_height=700, max_bytes=100_000, max_pixels=2_000_000, ) self.assertEqual(result['status'], 'ready', result['reason']) if result['temp_path']: os.unlink(result['temp_path']) self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000) self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning) for item in emitted)) def test_giant_image_header_is_readable_but_skipped_above_limit(self): # Header PNG deklaruje tyle pikseli co trzy problematyczne pliki # z logu: 14400 * 13872 = 199 756 800. Nie generujemy tych pikseli. fake = self.folder / 'scans' / '200mp.png' with io.BytesIO() as buffer: Image.new('RGB', (2, 2), 'red').save(buffer, format='PNG') content = bytearray(buffer.getvalue()) content[16:24] = struct.pack('>II', 14400, 13872) content[29:33] = struct.pack('>I', zlib.crc32(content[12:29])) fake.write_bytes(content) with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000): with warnings.catch_warnings(record=True) as emitted: warnings.simplefilter('always', Image.DecompressionBombWarning) self.assertEqual(main.file_resolution(str(fake), max_pixels=220_000_000), (14400, 13872)) with safe_image_open(str(fake), max_pixels=220_000_000) as source: self.assertEqual(source.width * source.height, 199_756_800) # Obrazu nie nalezy dekodowac bez limitu RAM. rejected = create_optimized_candidate(str(fake), max_pixels=150_000_000) self.assertEqual(rejected['status'], 'skip') self.assertIn('--max-image-mpixels=150', rejected['reason']) # Przypadek > 2 * limitu Pillow rowniez jest SKIP, nie ERROR. rejected_strict = create_optimized_candidate(str(fake), max_pixels=60_000_000) self.assertEqual(rejected_strict['status'], 'skip') self.assertIn('--max-image-mpixels=60', rejected_strict['reason']) self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000) self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning) for item in emitted)) def test_oversize_images_are_reported_as_skipped_not_errors(self): args = self.args('--optimize', '--dry-run', '--max-image-mpixels', '0.5', '--max-width', '500') original = self.image.read_bytes() images, _, prefix = main.collect_images(self.conn, self.cfg, args) with redirect_stdout(io.StringIO()) as output: stats = main.process_changes(self.conn, self.cfg, args, images, prefix) self.assertEqual(stats['errors'], 0) self.assertEqual(stats['planned'], 0) self.assertEqual(images[0]['optimize_status'], 'skip') self.assertIn('[SKIP-LIMIT]', output.getvalue()) self.assertEqual(self.image.read_bytes(), original) self.assertEqual(self.conn.files[0]['filesize'], 12345) def test_cli_end_to_end_read_only_generates_reports(self): csv_path = self.root / 'end.csv' json_path = self.root / 'end.json' with patch.object(main, 'load_config', return_value=self.cfg), \ patch.object(main, 'get_connection', return_value=self.conn), \ redirect_stdout(io.StringIO()): status = main.main(['--find-usage', '--no-text-scan', '--top', '10', '--report-csv', str(csv_path), '--report-json', str(json_path)]) self.assertEqual(status, 0) self.assertIn('https://forum.example.org/node/11', csv_path.read_text(encoding='utf-8-sig')) self.assertIn('"images"', json_path.read_text(encoding='utf-8')) self.assertEqual(self.conn.files[0]['filesize'], 12345) def test_csv_has_direct_link(self): args = self.args('--find-usage', '--site-url', 'https://forum.example.org', '--report-csv', str(self.root / 'out.csv')) images, _, prefix = main.collect_images(self.conn, self.cfg, args) images[0]['references'] = [ {'nid': 11, 'cid': None, 'title': 'Przypiety temat', 'node_type': 'forum', 'published': True, 'comment_published': None, 'sources': ['upload.fid'], 'confidence': 'pewne', 'url': 'https://forum.example.org/node/11', 'edit_url': 'https://forum.example.org/node/11/edit'} ] main.write_csv(args.report_csv, images, self.cfg, args) text = Path(args.report_csv).read_text(encoding='utf-8-sig') self.assertIn('https://forum.example.org/node/11', text) self.assertIn('Przypiety temat', text) if __name__ == '__main__': unittest.main()