Private
Public Access
addet test set
This commit is contained in:
1 parent
d370f992ce
commit
b0f35bf3cc
21 files changed
+1796
-183
No files matched your search
@@ -0,0 +1,137 @@
|
||||
"""Local trigger pictures: synchronized crop, persistence, API and deletion."""
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from halloween.event_store import EventStore
|
||||
from halloween.web import create_app
|
||||
|
||||
|
||||
def sample():
|
||||
image = Image.new('RGB', (100, 80), (20, 40, 180))
|
||||
# Distinct central crop: outside the chosen area must not be recorded.
|
||||
image.paste((180, 40, 20), (20, 16, 80, 64))
|
||||
stream = BytesIO()
|
||||
image.save(stream, 'JPEG', quality=95)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
REGION = [.2, .2, .8, .8]
|
||||
|
||||
|
||||
class StoreImageTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.root = Path(self.folder.name)
|
||||
self.store = EventStore(self.root)
|
||||
|
||||
def test_automatic_kinds_persist_cropped_picture_after_restart(self):
|
||||
for method in (self.store.record_detection_trigger, self.store.record_motion_trigger):
|
||||
event_id = method('sanft', jpeg=sample(), region=REGION)
|
||||
path = EventStore(self.root).image_path(event_id)
|
||||
with Image.open(path) as image:
|
||||
self.assertEqual(image.size, (60, 48))
|
||||
self.assertEqual(image.format, 'JPEG')
|
||||
self.assertGreater(image.getpixel((30, 24))[0], 150)
|
||||
self.assertLess(image.getpixel((30, 24))[2], 50)
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
|
||||
event = self.store.recent()[0]
|
||||
self.assertEqual(event['image_url'], f'/api/events/{event_id}/image')
|
||||
self.assertNotIn('image_filename', event)
|
||||
self.assertIsNone(event['profile_id'])
|
||||
|
||||
def test_delete_image_preserves_event_and_profile(self):
|
||||
event_id = self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
|
||||
profile = self.store.create_profile('Fiktiv')
|
||||
self.store.assign_profile(event_id, profile['id'])
|
||||
path = self.store.image_path(event_id)
|
||||
self.assertTrue(self.store.delete_image(event_id))
|
||||
self.assertFalse(path.exists())
|
||||
event = EventStore(self.root).recent()[0]
|
||||
self.assertIsNone(event['image_url'])
|
||||
self.assertEqual(event['profile_id'], profile['id'])
|
||||
self.assertFalse(self.store.delete_image(event_id))
|
||||
with self.assertRaises(KeyError):
|
||||
self.store.image_path(event_id)
|
||||
|
||||
def test_manual_events_and_migrated_old_events_have_no_picture(self):
|
||||
event_id = self.store.record_test_trigger('sanft')
|
||||
self.assertIsNone(self.store.recent()[0]['image_url'])
|
||||
with self.assertRaises(KeyError):
|
||||
self.store.image_path(event_id)
|
||||
old_root = self.root / 'legacy'
|
||||
old_root.mkdir()
|
||||
with sqlite3.connect(old_root / 'events.sqlite3') as connection:
|
||||
connection.execute('CREATE TABLE events (id INTEGER PRIMARY KEY, occurred_at TEXT, kind TEXT, mode TEXT)')
|
||||
connection.execute("INSERT INTO events VALUES (1,'2026-10-14T10:00:00+00:00','test_trigger','sanft')")
|
||||
old = EventStore(old_root)
|
||||
self.assertIsNone(old.recent()[0]['image_url'])
|
||||
self.assertEqual(old.recent()[0]['id'], 1)
|
||||
new_id = old.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
|
||||
self.assertTrue(old.image_path(new_id).exists())
|
||||
|
||||
def test_database_failure_removes_written_image(self):
|
||||
with patch.object(self.store, '_connect', side_effect=sqlite3.OperationalError('synthetic DB failure')):
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
|
||||
self.assertEqual(list(self.store.image_dir.iterdir()), [])
|
||||
self.assertEqual(self.store.recent(), [])
|
||||
|
||||
def test_image_failure_leaves_no_temp_file_or_database_event(self):
|
||||
jpeg = sample()
|
||||
with patch.object(Image.Image, 'save', side_effect=OSError('synthetic disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
self.store.record_motion_trigger('sanft', jpeg=jpeg, region=REGION)
|
||||
self.assertEqual(self.store.recent(), [])
|
||||
if self.store.image_dir.exists():
|
||||
self.assertEqual(list(self.store.image_dir.iterdir()), [])
|
||||
|
||||
def test_tampered_database_cannot_read_or_delete_outside_image_dir(self):
|
||||
outside = self.root / 'keep.txt'
|
||||
outside.write_text('keep')
|
||||
event_id = self.store.record_test_trigger('sanft')
|
||||
with sqlite3.connect(self.store.path) as connection:
|
||||
connection.execute('UPDATE events SET image_filename = ? WHERE id = ?', ('../keep.txt', event_id))
|
||||
with self.assertRaises(KeyError):
|
||||
self.store.image_path(event_id)
|
||||
with self.assertRaises(KeyError):
|
||||
self.store.delete_image(event_id)
|
||||
self.assertEqual(outside.read_text(), 'keep')
|
||||
self.assertIsNone(self.store.recent()[0]['image_url'])
|
||||
|
||||
|
||||
class ImageApiTest(unittest.TestCase):
|
||||
def test_view_and_delete_image_without_affecting_event(self):
|
||||
async def scenario():
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
app = create_app(demo=True, state_dir=Path(folder))
|
||||
event_id = app.state.events.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
|
||||
url = (await client.get('/api/events')).json()['events'][0]['image_url']
|
||||
image = await client.get(url)
|
||||
self.assertEqual(image.status_code, 200)
|
||||
self.assertEqual(image.headers['content-type'], 'image/jpeg')
|
||||
self.assertEqual(image.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(image.headers['x-content-type-options'], 'nosniff')
|
||||
self.assertEqual((await client.get('/api/events/999/image')).status_code, 404)
|
||||
self.assertEqual((await client.delete(url)).status_code, 403)
|
||||
self.assertEqual((await client.delete(url, headers={'X-Halloween-UI':'1'})).status_code, 200)
|
||||
self.assertEqual((await client.get(url)).status_code, 404)
|
||||
event = (await client.get('/api/events')).json()['events'][0]
|
||||
self.assertEqual(event['id'], event_id)
|
||||
self.assertIsNone(event['image_url'])
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user