2026-10-03 17:10:06 +08:00
"""Isolated regression tests: python -m unittest -v test_app."""
import hashlib
2026-10-04 19:22:31 +08:00
import atexit
2026-10-03 17:10:06 +08:00
import http.client
2026-10-03 17:15:09 +08:00
import io
2026-10-03 17:10:06 +08:00
import json
import os
2026-10-03 17:21:25 +08:00
import re
2026-10-03 17:10:06 +08:00
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
_data = tempfile . TemporaryDirectory ()
os . environ [ 'VISION_DATA' ] = _data . name
os . environ [ 'VISION_RECORDINGS' ] = _data . name + '/recordings'
import app
import playback
2026-10-04 19:22:31 +08:00
atexit . register ( app . DB . close )
2026-10-03 17:10:06 +08:00
class WebTests ( unittest . TestCase ):
@classmethod
def setUpClass ( cls ):
app . initialize ()
2026-10-03 17:41:52 +08:00
cls . admin = app . ACCOUNTS . save ({ 'username' : 'admin' , 'password' : 'test-password' , 'role' : 'admin' }, None )
2026-10-03 17:10:06 +08:00
cls . server = app . ThreadingHTTPServer (( '127.0.0.1' , 0 ), app . Handler )
cls . thread = threading . Thread ( target = cls . server . serve_forever , daemon = True )
cls . thread . start ()
2026-10-03 17:41:52 +08:00
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' ,
( hashlib . sha256 ( b 'f' * 64 ) . hexdigest (), time . time () + 3600 , cls . admin [ 'id' ]))
2026-10-03 17:10:06 +08:00
app . DB . commit ()
2026-10-03 17:41:52 +08:00
app . save_object ( 'cameras' , { 'id' : 'a' * 16 , 'name' : 'Example' , 'siteId' : 'test-site' , 'password' : 'private' })
2026-10-03 17:10:06 +08:00
@classmethod
def tearDownClass ( cls ):
cls . server . shutdown ()
cls . server . server_close ()
cls . thread . join ()
app . DB . close ()
_data . cleanup ()
2026-10-04 04:52:39 +08:00
def request ( self , path , authenticated = True , data = None , origin = None , token = None ):
2026-10-03 17:10:06 +08:00
c = http . client . HTTPConnection ( * self . server . server_address , timeout = 3 )
2026-10-04 04:52:39 +08:00
headers = { 'Cookie' : 'vision=' + ( token or 'f' * 64 )} if authenticated else {}
2026-10-03 17:10:06 +08:00
if origin :
headers [ 'Origin' ] = origin
if data is not None :
headers [ 'Content-Type' ] = 'application/json'
c . request ( 'GET' if data is None else 'POST' , path ,
None if data is None else json . dumps ( data ), headers )
r = c . getresponse ()
result = r . status , dict ( r . getheaders ()), r . read ()
c . close ()
return result
def test_media_and_api_require_login ( self ):
for path in [ '/api/state' , '/api/recordings' , '/media/playback' ,
'/media/live/cam_' + 'a' * 16 + '_hd/index.m3u8' ]:
with self . subTest ( path = path ):
self . assertEqual ( self . request ( path , False )[ 0 ], 401 )
2026-10-04 08:33:00 +08:00
def test_presets_api_enforces_control_and_management_roles ( self ):
camera = dict ( id = 'f' * 16 , name = 'Preset test' , siteId = 'preset-site' , enabled = True )
app . save_object ( 'sites' , dict ( id = 'preset-site' , name = 'Preset site' ))
app . save_object ( 'cameras' , camera )
viewer = app . ACCOUNTS . save ( dict ( username = 'preset-viewer' , password = 'test-password' , siteIds = [ 'preset-site' ], ptzControl = True ), self . admin )
token = '2' * 64
with app . LOCK :
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' , ( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , viewer [ 'id' ]))
app . DB . commit ()
path = '/api/ptz/presets'
payload = dict ( camera = camera [ 'id' ], action = 'goto' , token = '1' , revision = 'v1' )
with patch . object ( app . ptz . CONTROLLER , 'read_presets' , return_value = ({ 'items' :[]},{})), patch . object ( app . ptz . CONTROLLER , 'preset_command' , return_value = { 'ok' : True }) as command :
self . assertEqual ( self . request ( path + '?camera=' + camera [ 'id' ], False )[ 0 ], 401 )
code , _ , body = self . request ( path + '?camera=' + camera [ 'id' ], token = token )
self . assertEqual ( code , 200 ); self . assertFalse ( json . loads ( body )[ 'canManage' ])
self . assertEqual ( self . request ( path , data = payload , token = token )[ 0 ], 200 )
for action in [ 'create' , 'replace' , 'remove' ]:
self . assertEqual ( self . request ( path , data = dict ( payload , action = action ), token = token )[ 0 ], 403 )
self . assertEqual ( command . call_count , 1 )
self . assertEqual ( self . request ( path , data = dict ( payload , action = 'create' ))[ 0 ], 200 )
self . assertEqual ( self . request ( path , data = payload , origin = 'https://other.example' )[ 0 ], 403 )
app . ACCOUNTS . save ( dict ( viewer , ptzControl = False ), self . admin )
with app . LOCK :
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' , ( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , viewer [ 'id' ]))
app . DB . commit ()
self . assertEqual ( self . request ( path + '?camera=' + camera [ 'id' ], token = token )[ 0 ], 403 )
2026-10-03 17:10:06 +08:00
def test_static_security_headers ( self ):
code , headers , _ = self . request ( '/' , False )
self . assertEqual ( code , 200 )
self . assertEqual ( headers [ 'X-Frame-Options' ], 'DENY' )
self . assertIn ( "frame-ancestors 'none'" , headers [ 'Content-Security-Policy' ])
2026-10-03 17:21:25 +08:00
def test_live_quality_has_only_main_and_sub ( self ):
code , _ , body = self . request ( '/' , False )
self . assertEqual ( code , 200 )
select = re . search ( r '<select id="quality">(.*?)</select>' , body . decode ()) . group ( 1 )
self . assertEqual ( re . findall ( r '<option value="(.*?)">(.*?)</option>' , select ),
[( 'hd' , '主码流' ), ( 'compat' , '子码流' )])
2026-10-03 20:05:05 +08:00
def test_asset_urls_follow_release_version ( self ):
code , headers , body = self . request ( '/' , False )
self . assertEqual ( code , 200 )
self . assertEqual ( headers [ 'Cache-Control' ], 'no-store' )
self . assertNotIn ( b '__ASSET_VERSION__' , body )
assets = re . findall ( r '(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"' , body . decode ())
2026-10-04 19:22:31 +08:00
self . assertEqual ( len ( assets ), 27 )
2026-10-03 20:05:05 +08:00
version = ( app . ROOT / 'VERSION' ) . read_text () . strip ()
for asset in assets :
self . assertTrue ( asset . endswith ( '?v=' + version ))
self . assertEqual ( self . request ( asset , False )[ 0 ], 200 )
2026-10-03 17:10:06 +08:00
def test_origin_rejected ( self ):
self . assertEqual ( self . request ( '/api/storage' , data = {}, origin = 'https://example.org' )[ 0 ], 403 )
def test_invalid_ranges_and_paths ( self ):
base = '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration='
for duration in [ 'nan' , 'inf' , '-1' , '3601' ]:
self . assertEqual ( self . request ( base + duration )[ 0 ], 400 )
self . assertEqual ( self . request ( '/media/live/../../vision.db' )[ 0 ], 400 )
self . assertEqual ( self . request ( '/vision.db' , False )[ 0 ], 404 )
def test_compatible_playback_dispatch ( self ):
calls = []
def compatible ( handler , path , duration ):
calls . append (( path , duration ))
handler . answer ({ 'ok' : True })
with patch . object ( app . Handler , 'compatible_playback' , compatible ):
code , _ , _ = self . request ( '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10' )
self . assertEqual ( code , 200 )
self . assertEqual ( calls [ 0 ][ 1 ], 10 )
self . assertIn ( 'format=fmp4' , calls [ 0 ][ 0 ])
def test_original_download_dispatch ( self ):
calls = []
def proxy ( handler , port , path , download = False ):
calls . append (( path , download ))
handler . answer ({ 'ok' : True })
with patch . object ( app . Handler , 'proxy' , proxy ):
code , _ , _ = self . request ( '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10&download=1' )
self . assertEqual ( code , 200 )
self . assertIn ( 'format=mp4' , calls [ 0 ][ 0 ])
self . assertTrue ( calls [ 0 ][ 1 ])
def test_stable_object_order ( self ):
app . save_object ( 'sites' , { 'id' : 'b' , 'name' : 'Second' })
app . save_object ( 'sites' , { 'id' : 'a' , 'name' : 'First' })
first = app . objects ( 'sites' )
app . save_object ( 'sites' , first [ 0 ])
self . assertEqual ( app . objects ( 'sites' ), first )
def test_password_redacted ( self ):
self . assertNotIn ( 'password' , app . public_camera ( app . get_object ( 'cameras' , 'a' * 16 )))
2026-10-03 17:41:52 +08:00
def test_accounts_and_authorization ( self ):
member = app . ACCOUNTS . save ({ 'username' : 'member' , 'password' : 'password8' , 'familyAccess' : 'read' }, self . admin )
self . assertNotIn ( 'hash' , member )
self . assertNotIn ( 'salt' , member )
self . assertIsNotNone ( app . ACCOUNTS . verify ( 'member' , 'password8' ))
self . assertIsNone ( app . ACCOUNTS . verify ( 'member' , 'incorrect' ))
code , _ , body = self . request ( '/api/users' )
self . assertEqual ( code , 200 )
self . assertNotIn ( b '"hash"' , body )
token = 'e' * 64
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' ,( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , member [ 'id' ]))
app . DB . commit ()
def request ( path , data = None ):
c = http . client . HTTPConnection ( * self . server . server_address , timeout = 3 )
c . request ( 'POST' if data is not None else 'GET' , path , None if data is None else json . dumps ( data ),
{ 'Cookie' : 'vision=' + token , 'Content-Type' : 'application/json' })
r = c . getresponse (); status = r . status ; r . read (); c . close (); return status
for path in [ '/api/users' , '/api/audit' , '/media/live/cam_' + 'a' * 16 + '_hd/index.m3u8' ,
'/api/recordings?camera=' + 'a' * 16 ]:
self . assertEqual ( request ( path ), 403 )
2026-10-03 20:27:11 +08:00
for path in [ '/api/storage' , '/api/users' , '/api/family/person' , '/api/family/event' ]:
2026-10-03 17:41:52 +08:00
self . assertEqual ( request ( path ,{}), 403 )
self . assertEqual ( request ( '/api/family' ), 200 )
app . ACCOUNTS . save ( dict ( member , disabled = True ), self . admin )
self . assertEqual ( request ( '/api/family' ), 401 )
def test_account_constraints ( self ):
with self . assertRaises ( app . Problem ):
app . ACCOUNTS . save ( dict ( self . admin , disabled = True ), self . admin )
with self . assertRaises ( app . Problem ):
app . ACCOUNTS . save ({ 'username' : 'admin' , 'password' : 'password8' }, self . admin )
with self . assertRaises ( app . Problem ):
app . ACCOUNTS . save ({ 'username' : 'short' , 'password' : '123' }, self . admin )
def test_family_graph_validation_and_revision ( self ):
def save ( kind , data ):
return app . FAMILY . save ( kind , dict ( data , revision = app . setting ( 'familyRevision' , 0 )), self . admin )[ 'item' ]
p = save ( 'person' ,{ 'name' : '祖辈' , 'biography' : '第一行 \n 第二行' })
q = save ( 'person' ,{ 'name' : '子辈' })
r = save ( 'person' ,{ 'name' : '孙辈' })
first = save ( 'link' ,{ 'fromId' : p [ 'id' ], 'toId' : q [ 'id' ], 'kind' : 'parent' })
save ( 'link' ,{ 'fromId' : q [ 'id' ], 'toId' : r [ 'id' ], 'kind' : 'parent' })
for value in [{ 'fromId' : r [ 'id' ], 'toId' : p [ 'id' ], 'kind' : 'parent' },
{ 'fromId' : p [ 'id' ], 'toId' : p [ 'id' ], 'kind' : 'parent' },
{ 'fromId' : p [ 'id' ], 'toId' : r [ 'id' ], 'kind' : 'spouse' },
{ 'fromId' : p [ 'id' ], 'toId' : q [ 'id' ], 'kind' : 'parent' }]:
with self . assertRaises ( app . Problem ): save ( 'link' , value )
with self . assertRaises ( app . Problem ):
app . FAMILY . save ( 'person' ,{ 'name' : '过期编辑' , 'revision' : 0 }, self . admin )
with self . assertRaises ( app . Problem ):
save ( 'person' ,{ 'name' : '错误日期' , 'birthDate' : '2026-02-30' })
with self . assertRaises ( app . Problem ):
save ( 'person' ,{ 'name' : '错误日期' , 'birthDate' : '2020-01-01' , 'deathDate' : '1900-01-01' , 'lifeStatus' : 'deceased' })
save ( 'link' , dict ( first , active = False ))
self . assertFalse ( app . get_object ( 'family_links' , first [ 'id' ])[ 'active' ])
save ( 'link' , dict ( first , active = True ))
save ( 'person' , dict ( p , archived = True ))
self . assertTrue ( app . get_object ( 'people' , p [ 'id' ])[ 'archived' ])
save ( 'person' , dict ( p , archived = False ))
def test_legacy_migration_preserves_password_and_sessions ( self ):
# Exercise migration in a separate database without touching the HTTP fixture.
import sqlite3
from pathlib import Path
with tempfile . TemporaryDirectory () as folder :
db = sqlite3 . connect ( Path ( folder ) / 'migration.db' ); db . row_factory = sqlite3 . Row
db . executescript ( 'CREATE TABLE settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);CREATE TABLE sessions(hash TEXT PRIMARY KEY,expires REAL NOT NULL);' )
credentials = app . ACCOUNTS . password ( 'old-password' )
db . execute ( 'INSERT INTO settings VALUES (?,?)' ,( 'account' , json . dumps ( credentials )))
db . execute ( 'INSERT INTO sessions VALUES (?,?)' ,( 'legacy' , time . time () + 60 )); db . commit ()
with patch . object ( app , 'DB' , db ):
app . ACCOUNTS . initialize (); app . ACCOUNTS . initialize ()
self . assertEqual ( len ( app . objects ( 'users' )), 1 )
self . assertIsNotNone ( app . ACCOUNTS . verify ( 'admin' , 'old-password' ))
self . assertEqual ( db . execute ( 'SELECT user_id FROM sessions' ) . fetchone ()[ 0 ], app . objects ( 'users' )[ 0 ][ 'id' ])
self . assertIsNone ( app . setting ( 'account' ))
db . close ()
2026-10-03 17:10:06 +08:00
def test_playback_has_bound_and_cleanup ( self ):
playback . SLOTS . acquire ()
playback . SLOTS . acquire ()
try :
with self . assertRaises ( playback . PlaybackError ):
with playback . stream ( 'unused' , 'private' , 10 ):
self . fail ( 'No third conversion allowed' )
finally :
playback . SLOTS . release ()
playback . SLOTS . release ()
with patch ( 'playback.shutil.which' , return_value = None ):
with self . assertRaises ( playback . PlaybackError ):
with playback . stream ( 'unused' , 'private' , 10 ):
pass
self . assertTrue ( playback . SLOTS . acquire ( blocking = False ))
playback . SLOTS . release ()
def test_vaapi_uses_supported_cqp ( self ):
with patch ( 'playback.shutil.which' , return_value = 'ffmpeg' ), patch ( 'playback.os.access' , return_value = True ):
command = playback . command ( 'http://127.0.0.1/source' , 'private' , 10 )
self . assertEqual ( command [ command . index ( '-rc_mode' ) + 1 ], 'CQP' )
self . assertIn ( 'h264_vaapi' , command )
self . assertIn ( 'pipe:1' , command )
2026-10-03 18:29:18 +08:00
def test_ptz_requires_both_space_and_control_permission ( self ):
2026-10-04 11:19:14 +08:00
app . save_object ( 'sites' , dict ( id = 'permitted' , name = 'Permitted' ))
2026-10-03 18:29:18 +08:00
camera = dict ( id = 'e' * 16 , name = 'Test' , siteId = 'permitted' , enabled = True )
viewer = dict ( role = 'member' , siteIds = [ 'permitted' ], ptzControl = False )
with self . assertRaises ( app . Problem ): app . ACCOUNTS . control ( viewer , camera )
viewer [ 'ptzControl' ] = True
self . assertEqual ( app . ACCOUNTS . control ( viewer , camera ), camera )
viewer [ 'siteIds' ] = []
with self . assertRaises ( app . Problem ): app . ACCOUNTS . control ( viewer , camera )
self . assertEqual ( self . request ( '/api/ptz?camera=' + 'e' * 16 , False )[ 0 ], 401 )
def test_family_defaults_alive_and_rejects_death_for_alive ( self ):
p = app . FAMILY . person ({ 'name' : 'Example' }, None )
self . assertEqual ( p [ 'lifeStatus' ], 'alive' )
with self . assertRaises ( app . Problem ): app . FAMILY . person ({ 'name' : 'Example' , 'deathDate' : '2020-01-01' }, None )
self . assertEqual ( app . FAMILY . person ({ 'name' : 'Example' , 'lifeStatus' : 'deceased' , 'deathDate' : '2020-01-01' }, None )[ 'deathDate' ], '2020-01-01' )
2026-10-03 20:02:07 +08:00
def test_family_event_draft_publish_archive_and_restore ( self ):
def save ( data ):
return app . FAMILY . save ( 'event' , dict ( data , revision = app . setting ( 'familyRevision' , 0 )), self . admin )[ 'item' ]
p = app . FAMILY . save ( 'person' ,{ 'name' : 'Event person' , 'revision' : app . setting ( 'familyRevision' , 0 )}, self . admin )[ 'item' ]
reader = { 'role' : 'member' , 'familyAccess' : 'read' , 'personId' : p [ 'id' ]}
e = save ({ 'title' : 'First event' , 'eventDate' : '2024-02-10' , 'summary' : 'Summary' , 'body' : 'Line one \n Line two' , 'personIds' :[ p [ 'id' ], p [ 'id' ]]})
self . assertEqual ( e [ 'status' ], 'draft' ); self . assertEqual ( e [ 'personIds' ],[ p [ 'id' ]])
self . assertNotIn ( e [ 'id' ],[ r [ 'id' ] for r in app . FAMILY . snapshot ( reader )[ 'events' ]])
e = save ( dict ( e , status = 'published' ))
self . assertIn ( 'publishedAt' , e )
self . assertIn ( e [ 'id' ],[ r [ 'id' ] for r in app . FAMILY . snapshot ( reader )[ 'events' ]])
e = save ( dict ( e , archived = True ))
self . assertNotIn ( e [ 'id' ],[ r [ 'id' ] for r in app . FAMILY . snapshot ( reader )[ 'events' ]])
self . assertIn ( e [ 'id' ],[ r [ 'id' ] for r in app . FAMILY . snapshot ( self . admin )[ 'events' ]])
e = save ( dict ( e , archived = False ))
self . assertIn ( e [ 'id' ],[ r [ 'id' ] for r in app . FAMILY . snapshot ( reader )[ 'events' ]])
with self . assertRaises ( app . Problem ): app . FAMILY . save ( 'event' , dict ( e , revision = 0 ), self . admin )
with self . assertRaises ( app . Problem ): app . FAMILY . save ( 'event' , dict ( e , revision = app . setting ( 'familyRevision' , 0 )), reader )
with self . assertRaises ( app . Problem ): app . FAMILY . snapshot ({ 'role' : 'member' , 'familyAccess' : 'none' })
def test_family_event_validation ( self ):
good = { 'title' : 'Event' , 'eventDate' : '2020-02-29' , 'body' : 'Text' , 'status' : 'published' }
for changes in [{ 'eventDate' : '2023-02-29' },{ 'title' : '' },{ 'sourceUrl' : 'javascript:alert(1)' },
{ 'sourceUrl' : 'https://user:secret@example.com' },{ 'personIds' :[ 'missing' ]},
{ 'body' : 'x' * 12001 },{ 'status' : 'public' },{ 'archived' : 'false' },{ 'personIds' : 'wrong' },
{ 'body' : '' , 'summary' : '' , 'sourceUrl' : '' }]:
with self . subTest ( changes = list ( changes )):
with self . assertRaises ( app . Problem ): app . FAMILY . event ( dict ( good , ** changes ), None )
event = app . FAMILY . event ( dict ( good , sourceUrl = 'https://example.org/report' ), None )
self . assertEqual ( event [ 'sourceUrl' ], 'https://example.org/report' )
2026-10-03 20:27:11 +08:00
def test_event_accepts_full_chinese_body_and_multiline_summary ( self ):
payload = { 'title' : '中文长文' , 'eventDate' : '2026-10-03' , 'body' : '家' * 12000 , 'summary' : '第一段 \n 第二段' ,
'status' : 'published' , 'revision' : app . setting ( 'familyRevision' , 0 )}
self . assertGreater ( len ( json . dumps ( payload ) . encode ()), 16384 )
code , _ , body = self . request ( '/api/family/event' , data = payload )
self . assertEqual ( code , 200 )
saved = json . loads ( body )[ 'item' ]
self . assertEqual ( saved [ 'body' ], payload [ 'body' ])
self . assertEqual ( saved [ 'summary' ], payload [ 'summary' ])
for changes in [{ 'body' : '家' * 12001 },{ 'summary' : '家' * 401 },{ 'summary' : 'bad \x00 text' }]:
invalid = dict ( payload , ** changes , revision = app . setting ( 'familyRevision' , 0 ))
self . assertEqual ( self . request ( '/api/family/event' , data = invalid )[ 0 ], 400 )
def test_event_keeps_publication_history_and_omitted_status ( self ):
def save ( data ): return app . FAMILY . save ( 'event' , dict ( data , revision = app . setting ( 'familyRevision' , 0 )), self . admin )[ 'item' ]
event = save ({ 'title' : '记事' , 'eventDate' : '2026-10-03' , 'body' : '正文' , 'status' : 'published' })
published = event [ 'publishedAt' ]; created = event [ 'createdAt' ]
edited = dict ( event , title = '更新标题' ); edited . pop ( 'status' ); edited . pop ( 'publishedAt' )
event = save ( edited )
self . assertEqual ( event [ 'status' ], 'published' ); self . assertEqual ( event [ 'publishedAt' ], published )
event = save ( dict ( event , status = 'draft' ))
self . assertEqual ( event [ 'publishedAt' ], published )
reader = { 'role' : 'member' , 'familyAccess' : 'read' }
self . assertNotIn ( event [ 'id' ],[ e [ 'id' ] for e in app . FAMILY . snapshot ( reader )[ 'events' ]])
event = save ( dict ( event , status = 'published' ))
self . assertEqual ( event [ 'publishedAt' ], published ); self . assertEqual ( event [ 'createdAt' ], created )
def test_biological_birth_order_checked_on_link_and_person_changes ( self ):
def save ( kind , data ): return app . FAMILY . save ( kind , dict ( data , revision = app . setting ( 'familyRevision' , 0 )), self . admin )[ 'item' ]
parent = save ( 'person' ,{ 'name' : 'Parent' , 'birthDate' : '1980-01-01' })
child = save ( 'person' ,{ 'name' : 'Child' , 'birthDate' : '2000-01-01' })
relation = save ( 'link' ,{ 'fromId' : parent [ 'id' ], 'toId' : child [ 'id' ], 'kind' : 'parent' , 'lineage' : 'biological' })
for person , date in [( parent , '2000-01-01' ),( parent , '2001-01-01' ),( child , '1979-01-01' )]:
revision = app . setting ( 'familyRevision' , 0 )
with self . assertRaises ( app . Problem ): save ( 'person' , dict ( person , birthDate = date ))
self . assertEqual ( app . setting ( 'familyRevision' , 0 ), revision )
self . assertEqual ( app . get_object ( 'people' , person [ 'id' ])[ 'birthDate' ], person [ 'birthDate' ])
save ( 'link' , dict ( relation , active = False ))
save ( 'person' , dict ( parent , birthDate = '2001-01-01' ))
with self . assertRaises ( app . Problem ): save ( 'link' , dict ( relation , active = True ))
save ( 'link' , dict ( relation , active = True , lineage = 'adoptive' ))
def test_request_body_limit_is_bounded_and_invalid_length_is_400 ( self ):
from email.message import Message
from types import SimpleNamespace
for length in [ 'invalid' , '-1' , '0' , '131073' ]:
headers = Message (); headers [ 'Content-Type' ] = 'application/json' ; headers [ 'Content-Length' ] = length
fake = SimpleNamespace ( headers = headers , rfile = io . BytesIO ( b ' {} ' ))
with self . assertRaises ( app . Problem ) as caught : app . Handler . body ( fake , 131072 )
self . assertEqual ( caught . exception . status , 400 )
2026-10-03 20:02:07 +08:00
def test_event_endpoint_and_static_modules ( self ):
self . assertEqual ( self . request ( '/api/family/event' , False ,{ 'title' : 'Denied' })[ 0 ], 401 )
payload = { 'title' : 'API event' , 'eventDate' : '2026-01-01' , 'body' : 'Test only' , 'status' : 'published' , 'revision' : app . setting ( 'familyRevision' , 0 )}
self . assertEqual ( self . request ( '/api/family/event' , data = payload )[ 0 ], 200 )
for path in [ '/family-graph.js' , '/family-map.js' , '/family-map.css' , '/family-events.js' ]:
self . assertEqual ( self . request ( path , False )[ 0 ], 200 )
2026-10-03 17:15:09 +08:00
def test_empty_recording_day_is_not_service_failure ( self ):
error = app . urllib . error . HTTPError ( 'http://localhost/list' , 404 , 'Not Found' , {},
io . BytesIO ( b '{"error":"no recording segments found"}' ))
with patch ( 'app.urllib.request.urlopen' , side_effect = error ):
self . assertEqual ( app . media_json ( '/list?path=example' , playback = True ), [])
2026-10-04 04:58:56 +08:00
self . assertTrue ( error . fp . closed )
2026-10-03 17:15:09 +08:00
def test_real_media_service_error_is_preserved ( self ):
error = app . urllib . error . HTTPError ( 'http://localhost/list' , 500 , 'Error' , {}, io . BytesIO ( b ' {} ' ))
with patch ( 'app.urllib.request.urlopen' , side_effect = error ):
with self . assertRaises ( app . Problem ):
app . media_json ( '/list?path=example' , playback = True )
2026-10-04 04:58:56 +08:00
self . assertTrue ( error . fp . closed )
2026-10-03 17:15:09 +08:00
2026-10-04 04:52:39 +08:00
def family_save ( self , kind , data ):
return app . FAMILY . save ( kind , dict ( data , revision = app . setting ( 'familyRevision' , 0 )), self . admin )
def test_history_records_before_after_and_restore_as_new_version ( self ):
first = self . family_save ( 'person' , { 'name' : 'History original' , 'biography' : 'Original story' })
second = self . family_save ( 'person' , dict ( first [ 'item' ], name = 'History changed' ))
change = app . FAMILY . change ( self . admin , second [ 'revision' ])
self . assertEqual ( change [ 'before' ][ 'name' ], 'History original' )
self . assertEqual ( change [ 'after' ][ 'name' ], 'History changed' )
restored = app . FAMILY . restore ({ 'changeRevision' : second [ 'revision' ], 'side' : 'before' ,
'revision' : app . setting ( 'familyRevision' )}, self . admin )
self . assertEqual ( restored [ 'item' ][ 'name' ], 'History original' )
self . assertGreater ( restored [ 'revision' ], second [ 'revision' ])
entries = app . FAMILY . history ( self . admin , 'person' , first [ 'item' ][ 'id' ])[ 'items' ]
self . assertEqual ( len ( entries ), 3 )
self . assertEqual ( entries [ 0 ][ 'restoredFrom' ], second [ 'revision' ])
self . assertEqual ( app . FAMILY . change ( self . admin , second [ 'revision' ]), change )
def test_history_rejects_stale_restore_without_writing ( self ):
first = self . family_save ( 'person' , { 'name' : 'Stale restore' })
second = self . family_save ( 'person' , dict ( first [ 'item' ], name = 'Latest' ))
with self . assertRaises ( app . Problem ) as caught :
app . FAMILY . restore ( dict ( changeRevision = first [ 'revision' ], side = 'after' , revision = first [ 'revision' ]), self . admin )
self . assertEqual ( caught . exception . status , 409 )
self . assertEqual ( app . get_object ( 'people' , first [ 'item' ][ 'id' ])[ 'name' ], 'Latest' )
self . assertEqual ( app . setting ( 'familyRevision' ), second [ 'revision' ])
def test_restore_revalidates_current_birth_order ( self ):
old = self . family_save ( 'person' , { 'name' : 'Restore parent' , 'birthDate' : '1980-01-01' })
parent = self . family_save ( 'person' , dict ( old [ 'item' ], birthDate = '1960-01-01' ))[ 'item' ]
child = self . family_save ( 'person' , { 'name' : 'Restore child' , 'birthDate' : '1970-01-01' })[ 'item' ]
self . family_save ( 'link' , dict ( fromId = parent [ 'id' ], toId = child [ 'id' ], kind = 'parent' , lineage = 'biological' ))
with self . assertRaises ( app . Problem ):
app . FAMILY . restore ( dict ( changeRevision = old [ 'revision' ], side = 'after' , revision = app . setting ( 'familyRevision' )), self . admin )
self . assertEqual ( app . get_object ( 'people' , parent [ 'id' ])[ 'birthDate' ], '1960-01-01' )
def test_restore_link_cannot_create_new_cycle ( self ):
a = self . family_save ( 'person' , { 'name' : 'Cycle A' })[ 'item' ]
b = self . family_save ( 'person' , { 'name' : 'Cycle B' })[ 'item' ]
old = self . family_save ( 'link' , dict ( fromId = a [ 'id' ], toId = b [ 'id' ], kind = 'parent' ))
self . family_save ( 'link' , dict ( old [ 'item' ], active = False ))
self . family_save ( 'link' , dict ( fromId = b [ 'id' ], toId = a [ 'id' ], kind = 'parent' ))
with self . assertRaises ( app . Problem ):
app . FAMILY . restore ( dict ( changeRevision = old [ 'revision' ], side = 'after' , revision = app . setting ( 'familyRevision' )), self . admin )
self . assertFalse ( app . get_object ( 'family_links' , old [ 'item' ][ 'id' ])[ 'active' ])
def test_restore_event_preserves_first_publication_and_validates_scope ( self ):
draft = self . family_save ( 'event' , dict ( title = 'Restored story' , eventDate = '2020-01-01' , body = 'Draft' , status = 'draft' ))
published = self . family_save ( 'event' , dict ( draft [ 'item' ], body = 'Published' , status = 'published' ))
restored = app . FAMILY . restore ( dict ( changeRevision = draft [ 'revision' ], side = 'after' , revision = app . setting ( 'familyRevision' )), self . admin )
self . assertEqual ( restored [ 'item' ][ 'status' ], 'draft' )
self . assertEqual ( restored [ 'item' ][ 'publishedAt' ], published [ 'item' ][ 'publishedAt' ])
self . assertNotIn ( draft [ 'item' ][ 'id' ], [ e [ 'id' ] for e in app . FAMILY . snapshot ( dict ( role = 'member' , familyAccess = 'read' ))[ 'events' ]])
for side in [ 'before' , 'invalid' ]:
with self . assertRaises ( app . Problem ):
app . FAMILY . restore ( dict ( changeRevision = draft [ 'revision' ], side = side , revision = app . setting ( 'familyRevision' )), self . admin )
def test_history_pagination_is_complete_and_omits_story_bodies ( self ):
item = self . family_save ( 'person' , { 'name' : 'Pagination' , 'biography' : 'Private full story' })[ 'item' ]
for i in range ( 23 ): item = self . family_save ( 'person' , dict ( item , note = str ( i )))[ 'item' ]
first = app . FAMILY . history ( self . admin , 'person' , item [ 'id' ])
second = app . FAMILY . history ( self . admin , 'person' , item [ 'id' ], first [ 'nextBefore' ])
revisions = [ v [ 'revision' ] for v in first [ 'items' ] + second [ 'items' ]]
self . assertEqual ( len ( revisions ), 24 )
self . assertEqual ( len ( set ( revisions )), 24 )
self . assertIsNone ( second [ 'nextBefore' ])
self . assertNotIn ( 'Private full story' , json . dumps ( first ))
def test_history_and_restore_endpoints_enforce_edit_permission ( self ):
item = self . family_save ( 'person' , { 'name' : 'Permission history' })
reader = app . ACCOUNTS . save ( dict ( username = 'history-reader' , password = 'test-reader' , familyAccess = 'read' ), self . admin )
token = 'd' * 64
with app . LOCK :
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' , ( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , reader [ 'id' ]))
app . DB . commit ()
path = '/api/family/history?kind=person&id=' + item [ 'item' ][ 'id' ]
for target in [ path , '/api/family/change?revision=' + str ( item [ 'revision' ])]:
self . assertEqual ( self . request ( target , token = token )[ 0 ], 403 )
self . assertEqual ( self . request ( target , authenticated = False )[ 0 ], 401 )
self . assertEqual ( self . request ( target )[ 0 ], 200 )
self . assertEqual ( self . request ( '/api/family/restore' , token = token , data = {})[ 0 ], 403 )
for number in [ 'bad' , '-1' , '0' , '99999999999999999999999' ]:
self . assertEqual ( self . request ( '/api/family/change?revision=' + number )[ 0 ], 400 )
def test_family_history_transaction_rolls_back_on_storage_failure ( self ):
before = app . setting ( 'familyRevision' , 0 )
with app . LOCK :
app . DB . execute ( "CREATE TRIGGER fail_history BEFORE INSERT ON family_changes BEGIN SELECT RAISE(ABORT, 'test failure'); END" )
app . DB . commit ()
try :
import sqlite3
with self . assertRaises ( sqlite3 . IntegrityError ): self . family_save ( 'person' , { 'name' : 'Must not persist' })
self . assertEqual ( app . setting ( 'familyRevision' , 0 ), before )
self . assertFalse ( any ( p [ 'name' ] == 'Must not persist' for p in app . objects ( 'people' )))
finally :
with app . LOCK : app . DB . execute ( 'DROP TRIGGER fail_history' ); app . DB . commit ()
def test_concurrent_family_saves_accept_only_one_revision ( self ):
from concurrent.futures import ThreadPoolExecutor
revision = app . setting ( 'familyRevision' , 0 ); barrier = threading . Barrier ( 2 )
def save ( index ):
barrier . wait ()
try : app . FAMILY . save ( 'person' , dict ( name = 'Concurrent ' + str ( index ), revision = revision ), self . admin ); return 200
except app . Problem as e : return e . status
with ThreadPoolExecutor ( max_workers = 2 ) as pool : self . assertEqual ( sorted ( pool . map ( save , range ( 2 ))), [ 200 , 409 ])
self . assertEqual ( app . setting ( 'familyRevision' ), revision + 1 )
def test_password_change_revokes_all_own_sessions_and_keeps_other_accounts ( self ):
member = app . ACCOUNTS . save ( dict ( username = 'password-member' , password = 'old-password' , familyAccess = 'read' ), self . admin )
tokens = [ '1' * 64 , '2' * 64 ]
with app . LOCK :
for token in tokens : app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' , ( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , member [ 'id' ]))
app . DB . commit ()
code , headers , _ = self . request ( '/api/account/password' , token = tokens [ 0 ], data = dict ( currentPassword = 'old-password' , newPassword = 'new-password' , id = self . admin [ 'id' ], role = 'admin' ))
self . assertEqual ( code , 200 ); self . assertIn ( 'Max-Age=0' , headers [ 'Set-Cookie' ])
self . assertIsNone ( app . ACCOUNTS . verify ( member [ 'username' ], 'old-password' ))
self . assertEqual ( app . ACCOUNTS . verify ( member [ 'username' ], 'new-password' )[ 'role' ], 'member' )
for token in tokens : self . assertEqual ( self . request ( '/api/family' , token = token )[ 0 ], 401 )
self . assertEqual ( self . request ( '/api/users' )[ 0 ], 200 )
def test_password_validation_and_rate_limit ( self ):
actor = app . ACCOUNTS . save ( dict ( username = 'password-validation' , password = 'old-password' ), self . admin )
for data in [ dict ( currentPassword = 'old-password' , newPassword = 'tiny' ), dict ( currentPassword = 'old-password' , newPassword = 'old-password' )]:
with self . assertRaises ( app . Problem ): app . ACCOUNTS . change_password ( data , actor )
for _ in range ( 8 ):
with self . assertRaises ( app . Problem ) as caught : app . ACCOUNTS . change_password ( dict ( currentPassword = 'wrong' , newPassword = 'new-password' ), actor )
self . assertEqual ( caught . exception . status , 403 )
with self . assertRaises ( app . Problem ) as caught : app . ACCOUNTS . change_password ( dict ( currentPassword = 'old-password' , newPassword = 'new-password' ), actor )
self . assertEqual ( caught . exception . status , 429 )
self . assertIsNotNone ( app . ACCOUNTS . verify ( actor [ 'username' ], 'old-password' ))
self . assertEqual ( self . request ( '/api/account/password' , authenticated = False , data = {})[ 0 ], 401 )
self . assertEqual ( self . request ( '/api/account/password' , data = {}, origin = 'http://[bad' )[ 0 ], 403 )
def test_object_ids_and_hosts_reject_malformed_input_without_500 ( self ):
for endpoint in [ 'sites' , 'assets' , 'cameras' , 'recorders' ]:
for value in [[], {}, 17 , True ]:
self . assertEqual ( self . request ( '/api/' + endpoint , data = { 'id' : value })[ 0 ], 400 )
self . assertEqual ( self . request ( '/api/' + endpoint , data = { 'id' : '9' * 16 })[ 0 ], 404 )
for value in [ None , 123 , [], {}, True ]:
with self . assertRaises ( app . Problem ): app . host_address ( value )
def test_concurrent_camera_capacity_and_asset_space_move ( self ):
from concurrent.futures import ThreadPoolExecutor
site = app . save_site ({ 'name' : 'Capacity space' })
other = app . save_site ({ 'name' : 'Moved space' })
asset = app . save_asset ( dict ( name = 'Single lens' , siteId = site [ 'id' ], lensCount = 1 ))
barrier = threading . Barrier ( 2 )
def save ( index ):
barrier . wait ()
try : app . save_camera ( dict ( name = 'Concurrent lens ' + str ( index ), assetId = asset [ 'id' ], host = '192.168.0.2' )); return 200
except app . Problem as e : return e . status
with patch . object ( app , 'write_media_config' ):
with ThreadPoolExecutor ( max_workers = 2 ) as pool : self . assertEqual ( sorted ( pool . map ( save , range ( 2 ))), [ 200 , 400 ])
attached = [ c for c in app . objects ( 'cameras' ) if c . get ( 'assetId' ) == asset [ 'id' ]]
self . assertEqual ( len ( attached ), 1 )
app . save_asset ( dict ( asset , siteId = other [ 'id' ]))
self . assertEqual ( app . get_object ( 'cameras' , attached [ 0 ][ 'id' ])[ 'siteId' ], other [ 'id' ])
def test_recorder_updates_are_reflected_in_channel_endpoint_without_password ( self ):
site = app . save_site ({ 'name' : 'Recorder test' })
asset = app . save_asset ( dict ( name = 'Recorder lens' , siteId = site [ 'id' ], lensCount = 1 ))
with patch . object ( app , 'write_media_config' ):
r = app . save_recorder ( dict ( name = 'NVR test' , siteId = site [ 'id' ], host = '192.168.0.8' , username = 'test' , password = 'private' ))
camera = app . save_camera ( dict ( name = 'Channel' , assetId = asset [ 'id' ], sourceKind = 'recorder' , recorderId = r [ 'id' ]))
app . save_recorder ( dict ( r , host = '192.168.0.9' , username = 'updated' ))
current = app . public_camera ( app . get_object ( 'cameras' , camera [ 'id' ]))
self . assertEqual ( current [ 'host' ], '192.168.0.9' )
self . assertEqual ( current [ 'username' ], 'updated' )
self . assertTrue ( current [ 'passwordConfigured' ]); self . assertNotIn ( 'password' , current )
def test_camera_settings_admin_only_and_errors_are_explicit ( self ):
user = app . ACCOUNTS . save ( dict ( username = 'settings-member' , password = 'settings-pass' , siteIds = [ 'test-site' ] if app . get_object ( 'sites' , 'test-site' ) else []), self . admin )
token = '3' * 64
with app . LOCK :
app . DB . execute ( 'INSERT INTO sessions VALUES (?,?,?)' , ( hashlib . sha256 ( token . encode ()) . hexdigest (), time . time () + 3600 , user [ 'id' ])); app . DB . commit ()
path = '/api/camera/settings?camera=' + 'a' * 16
self . assertEqual ( self . request ( path , token = token )[ 0 ], 403 )
self . assertEqual ( self . request ( '/api/camera/settings' , data = {}, token = token )[ 0 ], 403 )
self . assertEqual ( self . request ( path , authenticated = False )[ 0 ], 401 )
with patch . object ( app . camera_settings . CONTROLLER , 'read' , return_value = ({ 'device' :{ 'Model' : 'Example' }, 'revision' : 'one' },{})):
self . assertEqual ( self . request ( path )[ 0 ], 200 )
with patch . object ( app . camera_settings . CONTROLLER , 'save' , side_effect = ValueError ( 'unsupported' )):
self . assertEqual ( self . request ( '/api/camera/settings' , data = { 'camera' : 'a' * 16 })[ 0 ], 400 )
with patch . object ( app . camera_settings . CONTROLLER , 'save' , side_effect = OSError ()):
self . assertEqual ( self . request ( '/api/camera/settings' , data = { 'camera' : 'a' * 16 })[ 0 ], 502 )
def test_asset_move_rolls_back_all_channels_on_failure ( self ):
import sqlite3
site = app . save_site ({ 'name' : 'Original site atomic' })
other = app . save_site ({ 'name' : 'New site atomic' })
asset = app . save_asset ( dict ( name = 'Atomic move' , siteId = site [ 'id' ], lensCount = 1 ))
with patch . object ( app , 'write_media_config' ):
camera = app . save_camera ( dict ( name = 'Atomic lens' , assetId = asset [ 'id' ], host = '192.168.0.2' ))
with app . LOCK :
app . DB . execute ( "CREATE TRIGGER fail_camera_move BEFORE UPDATE ON cameras BEGIN SELECT RAISE(ABORT,'test'); END" ); app . DB . commit ()
try :
with self . assertRaises ( sqlite3 . IntegrityError ): app . save_asset ( dict ( asset , siteId = other [ 'id' ]))
self . assertEqual ( app . get_object ( 'assets' , asset [ 'id' ])[ 'siteId' ], site [ 'id' ])
self . assertEqual ( app . get_object ( 'cameras' , camera [ 'id' ])[ 'siteId' ], site [ 'id' ])
finally :
with app . LOCK : app . DB . execute ( 'DROP TRIGGER fail_camera_move' ); app . DB . commit ()
2026-10-03 17:10:06 +08:00
if __name__ == '__main__' :
unittest . main ()