289 lines
20 KiB
Python
289 lines
20 KiB
Python
"""Two-household HTTP isolation and management succession regressions."""
|
|
import hashlib
|
|
import http.client
|
|
import json
|
|
import secrets
|
|
import sqlite3
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
# Reuse only module initialization; never inherit the original WebTests.
|
|
from test_app import app
|
|
|
|
|
|
class HouseholdTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
db = sqlite3.connect(':memory:', check_same_thread=False)
|
|
db.row_factory = sqlite3.Row
|
|
self.addCleanup(db.close)
|
|
db.executescript('''
|
|
CREATE TABLE settings (key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
CREATE TABLE sessions (hash TEXT PRIMARY KEY,expires REAL NOT NULL);
|
|
CREATE TABLE audit (id INTEGER PRIMARY KEY,at TEXT,action TEXT,name TEXT);
|
|
''')
|
|
for table in ('sites', 'assets', 'recorders', 'cameras'):
|
|
db.execute('CREATE TABLE '+table+' (id TEXT PRIMARY KEY,body TEXT NOT NULL)')
|
|
for attr, value in [('DB', db), ('DATA', Path(self.temp.name)),
|
|
('write_media_config', lambda: None)]:
|
|
p = patch.object(app, attr, value); p.start(); self.addCleanup(p.stop)
|
|
app.initialize()
|
|
self.root = app.ACCOUNTS.save(dict(username='admin', password='fixture-only', role='admin'), None)
|
|
self.aid = self.root['familyId']
|
|
self.bid = app.HOUSEHOLDS.save({'name': '乙家庭'}, self.root)['id']
|
|
self.roots = {h: app.HOUSEHOLDS.context(self.root, h) for h in (self.aid, self.bid)}
|
|
self.managers, self.members, self.sites, self.cameras, self.assets = {}, {}, {}, {}, {}
|
|
for i, home in enumerate((self.aid, self.bid)):
|
|
actor = self.roots[home]
|
|
self.sites[home] = app.save_site({'name': 'home-'+str(i)}, actor)
|
|
self.assets[home] = app.save_asset(dict(name='asset-'+str(i), siteId=self.sites[home]['id']), actor)
|
|
self.cameras[home] = app.save_camera(dict(name='camera-'+str(i), assetId=self.assets[home]['id'],
|
|
host='192.168.3.'+str(10+i), enabled=True), actor)
|
|
self.managers[home] = app.ACCOUNTS.save(dict(username='manager'+str(i), password='fixture-only', role='family_admin'), actor)
|
|
self.members[home] = app.ACCOUNTS.save(dict(username='member'+str(i), password='fixture-only',
|
|
siteIds=[self.sites[home]['id']], familyAccess='edit', ptzControl=True), actor)
|
|
self.owner, self.other = self.managers[self.aid], self.managers[self.bid]
|
|
self.backup = app.ACCOUNTS.save(dict(username='backup', password='fixture-only', role='family_admin'), self.root)
|
|
self.tokens = {}
|
|
for user in [self.root, self.backup, *self.managers.values(), *self.members.values()]:
|
|
token = secrets.token_hex(32); self.tokens[user['id']] = token
|
|
db.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id']))
|
|
db.commit()
|
|
def state():
|
|
return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras','devices')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}}
|
|
p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop)
|
|
self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
|
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
|
|
self.addCleanup(self.stop_server)
|
|
|
|
def stop_server(self):
|
|
self.server.shutdown(); self.server.server_close(); self.thread.join()
|
|
|
|
def request(self, path, user=None, data=None, home=None):
|
|
user = user or self.owner
|
|
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
|
|
headers = {'Cookie': 'vision='+self.tokens[user['id']]}
|
|
if home: headers['X-Household-Id'] = home
|
|
if data is not None: headers['Content-Type'] = 'application/json'
|
|
c.request('GET' if data is None else 'POST', path, json.dumps(data) if data is not None else None, headers)
|
|
response = c.getresponse(); raw = response.read(); code = response.status; c.close()
|
|
return code, json.loads(raw)
|
|
|
|
def leadership(self, user=None, **fields):
|
|
data = dict(id=self.aid, ownerId=self.owner['id'], successorId=self.backup['id'], revision=0, reason='提前安排备用负责人')
|
|
return self.request('/api/households/leadership', user or self.root, data=dict(data, **fields))
|
|
|
|
def test_state_and_accounts_are_scoped(self):
|
|
for home in (self.aid, self.bid):
|
|
for user in (self.managers[home], self.members[home]):
|
|
code, body = self.request('/api/state', user)
|
|
self.assertEqual(code, 200)
|
|
for table in ('sites','assets','cameras'):
|
|
self.assertEqual(len(body[table]), 1)
|
|
self.assertEqual(body['cameras'][0]['id'], self.cameras[home]['id'])
|
|
self.assertEqual([h['id'] for h in body['households']], [home])
|
|
self.assertEqual(body['storage'], {}); self.assertEqual(body['scan'], {})
|
|
code, body = self.request('/api/users')
|
|
self.assertEqual(code, 200)
|
|
self.assertTrue(all(u['familyId']==self.aid and u['role']!='admin' for u in body['users']))
|
|
self.assertNotIn('hash', json.dumps(body)); self.assertNotIn('salt', json.dumps(body))
|
|
|
|
def test_forged_household_header_denied_root_can_switch(self):
|
|
for user in (self.owner, self.members[self.aid]):
|
|
for path in ('/api/session','/api/state','/api/family','/api/households'):
|
|
self.assertEqual(self.request(path, user, home=self.bid)[0], 403)
|
|
code, body = self.request('/api/state', self.root, home=self.bid)
|
|
self.assertEqual(code, 200); self.assertEqual(body['familyId'], self.bid)
|
|
self.assertEqual(body['cameras'][0]['id'], self.cameras[self.bid]['id'])
|
|
|
|
def test_guessed_camera_ids_cannot_access_media_or_controls(self):
|
|
camera = self.cameras[self.bid]['id']
|
|
paths = [f'/media/live/cam_{camera}_{quality}/{file}' for quality in ('main','sub','hd','compat')
|
|
for file in ('index.m3u8','init.mp4','segment1.mp4')]
|
|
paths += [f'/api/recordings?camera={camera}', f'/media/playback?camera={camera}',
|
|
f'/media/playback?camera={camera}&download=1', f'/api/ptz?camera={camera}',
|
|
f'/api/ptz/presets?camera={camera}', f'/api/camera/settings?camera={camera}']
|
|
with patch.object(app.Handler, 'proxy') as proxy:
|
|
for user in (self.owner,self.members[self.aid]):
|
|
for path in paths:
|
|
with self.subTest(path=path): self.assertIn(self.request(path,user)[0], (403,404))
|
|
for path in ('/api/ptz','/api/ptz/presets','/api/camera/settings'):
|
|
self.assertIn(self.request(path,user,dict(camera=camera, action='stop'))[0], (403,404))
|
|
proxy.assert_not_called()
|
|
|
|
def test_own_media_access_and_ungranted_site(self):
|
|
def proxy(handler,*args,**kwargs): handler.answer({'ok':True})
|
|
camera = self.cameras[self.aid]['id']; path = f'/media/live/cam_{camera}_hd/index.m3u8'
|
|
with patch.object(app.Handler,'proxy',proxy):
|
|
self.assertEqual(self.request(path,self.members[self.aid])[0],200)
|
|
member=dict(self.members[self.aid],siteIds=[])
|
|
# Update DB directly to retain token and exercise per-request permission lookup.
|
|
app.save_object('users',dict(app.get_object('users',member['id']),siteIds=[]))
|
|
self.assertEqual(self.request(path,member)[0],403)
|
|
self.assertEqual(self.request(path,self.owner)[0],200)
|
|
|
|
def test_cross_household_edits_references_and_ownership_blocked(self):
|
|
cases=[('/api/sites',dict(self.sites[self.bid],name='stolen')),
|
|
('/api/assets',dict(name='bad',siteId=self.sites[self.bid]['id'])),
|
|
('/api/cameras',dict(name='bad',assetId=self.assets[self.bid]['id'])),
|
|
('/api/recorders',dict(name='bad',siteId=self.sites[self.bid]['id'])),
|
|
('/api/sites',dict(self.sites[self.aid],familyId=self.bid))]
|
|
for path,data in cases: self.assertIn(self.request(path,data=data)[0],(403,404))
|
|
self.assertEqual(self.request('/api/sites',data=dict(self.sites[self.aid],name='own edit'))[0],200)
|
|
|
|
def test_other_device_address_and_onvif_credentials_protected(self):
|
|
with patch.object(app.onvif,'inspect') as inspect:
|
|
for data in (dict(id=self.cameras[self.bid]['id']),dict(host=self.cameras[self.bid]['host'],username='admin')):
|
|
self.assertIn(self.request('/api/onvif',data=data)[0],(403,404))
|
|
inspect.assert_not_called()
|
|
self.assertEqual(self.request('/api/cameras',data=dict(name='duplicate',assetId=self.assets[self.aid]['id'],host=self.cameras[self.bid]['host']))[0],403)
|
|
|
|
def test_manager_cannot_escalate_or_touch_another_family_user(self):
|
|
for data in (dict(username='root2',password='fixture-only',role='admin'),
|
|
dict(self.other,name='stolen'), dict(self.root,name='hijack'),
|
|
dict(username='cross',password='fixture-only',siteIds=[self.sites[self.bid]['id']]),
|
|
dict(username='cross',password='fixture-only',relatedToId=self.other['id'])):
|
|
self.assertIn(self.request('/api/users',data=data)[0],(403,404))
|
|
|
|
def test_global_operations_require_super_admin(self):
|
|
for path,data in (('/api/storage',{}),('/api/discover',{}),('/api/households',{'name':'unauthorized'})):
|
|
self.assertEqual(self.request(path,data=data)[0],403)
|
|
self.assertEqual(self.request('/api/audit')[0],403)
|
|
self.assertEqual(self.request('/api/households',self.members[self.aid],{'name':'unauthorized'})[0],403)
|
|
|
|
def test_genealogy_revisions_history_restore_and_refs_isolated(self):
|
|
a = app.FAMILY.save('person',dict(name='A private',revision=0),self.owner)['item']
|
|
b = app.FAMILY.save('person',dict(name='B private',revision=0),self.other)['item']
|
|
self.assertEqual(self.request('/api/family')[1]['revision'],1)
|
|
self.assertEqual([p['name'] for p in self.request('/api/family')[1]['people']],['A private'])
|
|
revision=app.FAMILY.history(self.other,'person',b['id'])['items'][0]['revision']
|
|
for path in (f'/api/family/history?kind=person&id={b["id"]}', f'/api/family/change?revision={revision}'):
|
|
self.assertEqual(self.request(path)[0],404)
|
|
for path,data in (('/api/family/restore',dict(changeRevision=revision,side='after',revision=1)),
|
|
('/api/family/person',dict(b,revision=1)),
|
|
('/api/family/link',dict(fromId=a['id'],toId=b['id'],kind='parent',revision=1)),
|
|
('/api/family/event',dict(personIds=[b['id']],revision=1))):
|
|
self.assertIn(self.request(path,data=data)[0],(403,404))
|
|
self.assertEqual(self.request('/api/users',data=dict(username='crossperson',password='fixture-only',personId=b['id']))[0],404)
|
|
|
|
def test_leadership_backup_and_explicit_emergency_handover(self):
|
|
self.assertEqual(self.leadership()[0],200)
|
|
# Backup remains operational even if the owner's browser is unavailable.
|
|
self.assertEqual(self.request('/api/sites',self.backup,dict(name='emergency operation'))[0],200)
|
|
self.assertEqual(self.leadership(self.backup,revision=1,ownerId=self.backup['id'],successorId='')[0],403)
|
|
self.assertEqual(self.leadership(self.root,revision=1,ownerId=self.backup['id'],successorId=self.owner['id'],reason='家主无法操作,由超级管理员办理交接')[0],200)
|
|
home=app.get_object('households',self.aid)
|
|
self.assertEqual(home['ownerId'],self.backup['id']);self.assertEqual(len(home['leadershipHistory']),2)
|
|
self.assertEqual(app.get_object('users',self.owner['id'])['role'],'family_admin')
|
|
self.assertEqual(self.leadership(self.owner,revision=2)[0],403)
|
|
self.assertEqual(self.request('/api/households',self.members[self.aid])[1]['households'][0].get('leadershipHistory'),None)
|
|
|
|
def test_leadership_conflicts_and_account_lockout_prevention(self):
|
|
self.assertEqual(self.leadership()[0],200)
|
|
self.assertEqual(self.leadership()[0],409)
|
|
for uid in (self.owner['id'],self.backup['id']):
|
|
user=app.get_object('users',uid)
|
|
for fields in ({'disabled':True},{'role':'member'}):
|
|
self.assertEqual(self.request('/api/users',self.root,dict(user,**fields))[0],400)
|
|
self.assertEqual(self.request('/api/users',self.backup,dict(self.owner,password='hijack-password'))[0],403)
|
|
self.assertEqual(self.leadership(revision=1,successorId=self.other['id'])[0],404)
|
|
self.assertEqual(self.leadership(revision=1,successorId=self.owner['id'])[0],400)
|
|
self.assertEqual(self.leadership(revision=1,ownerId=self.members[self.aid]['id'])[0],400)
|
|
# Editing household metadata must preserve leadership and its history.
|
|
self.assertEqual(self.request('/api/households',data=dict(id=self.aid,name='renamed'))[0],200)
|
|
self.assertEqual(app.get_object('households',self.aid)['ownerId'],self.owner['id'])
|
|
|
|
def test_legacy_migration_idempotent_preserves_credentials(self):
|
|
user=app.get_object('users',self.owner['id']);user.pop('familyId')
|
|
app.save_object('users',user)
|
|
before=app.DB.execute('SELECT * FROM sessions ORDER BY hash').fetchall()
|
|
app.HOUSEHOLDS.initialize();app.HOUSEHOLDS.initialize()
|
|
saved=app.get_object('users',user['id']);self.assertEqual(saved.pop('familyId'),self.aid)
|
|
self.assertEqual(saved,user)
|
|
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
|
|
|
|
def test_leadership_member_requires_explicit_grant_and_revokes_old_session(self):
|
|
member=self.members[self.aid]
|
|
self.assertEqual(self.leadership(ownerId=member['id'],successorId='')[0],400)
|
|
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
|
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement='true')[0],400)
|
|
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement=True)[0],200)
|
|
self.assertEqual(app.get_object('users',member['id'])['role'],'family_admin')
|
|
home=app.get_object('households',self.aid)
|
|
self.assertEqual(home['ownerId'],member['id'])
|
|
self.assertEqual(home['leadershipHistory'][-1]['promotedUserIds'],[member['id']])
|
|
self.assertEqual(app.DB.execute('SELECT count(*) FROM sessions WHERE user_id=?',(member['id'],)).fetchone()[0],0)
|
|
|
|
def test_leadership_grant_does_not_bypass_scope_or_partial_validation(self):
|
|
member=self.members[self.aid]
|
|
self.assertEqual(self.leadership(ownerId=member['id'],successorId=self.other['id'],grantManagement=True)[0],404)
|
|
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
|
self.assertEqual(app.get_object('households',self.aid).get('leadershipRevision',0),0)
|
|
disabled=dict(self.backup,disabled=True);app.save_object('users',disabled)
|
|
self.assertEqual(self.leadership(ownerId=member['id'],grantManagement=True)[0],400)
|
|
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
|
self.assertEqual(self.leadership(user=member,ownerId=member['id'],successorId='',grantManagement=True)[0],403)
|
|
|
|
def test_office_factory_and_custom_grouping_are_independent(self):
|
|
for kind in ('office','factory'):
|
|
code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
|
|
self.assertEqual(code,200);self.assertEqual(scope['kind'],kind)
|
|
self.assertEqual(self.request('/api/device-view',data={'groups':['manager','type','space']})[0],200)
|
|
self.assertEqual(app.get_object('households',self.aid)['deviceGrouping'],['manager','type','space'])
|
|
self.assertNotIn('deviceGrouping',app.get_object('households',self.bid))
|
|
self.assertEqual(self.request('/api/device-view',data={'groups':[]})[0],200)
|
|
for groups in (['type','type'],['unknown'],'type',[{}]):
|
|
self.assertEqual(self.request('/api/device-view',data={'groups':groups})[0],400)
|
|
self.assertEqual(self.request('/api/device-view',self.members[self.aid],{'groups':['type']})[0],403)
|
|
|
|
def test_region_hierarchy_has_no_fixed_depth_and_rejects_cycles(self):
|
|
parent=self.sites[self.aid]
|
|
for n in range(160):
|
|
parent=app.save_site(dict(name='Region '+str(n),parentId=parent['id'],category='custom '+str(n)),self.owner)
|
|
self.assertEqual(len(app.HOUSEHOLDS.objects(self.owner,'sites')),161)
|
|
for child in (parent,self.sites[self.aid]):
|
|
self.assertEqual(self.request('/api/sites',data=dict(child,parentId=parent['id']))[0],400)
|
|
self.assertEqual(self.request('/api/sites',data=dict(parent,parentId=self.sites[self.bid]['id']))[0],404)
|
|
|
|
def test_subregion_grants_are_explicit_and_checked_per_request(self):
|
|
child=app.save_site(dict(name='Bedroom',parentId=self.sites[self.aid]['id']),self.owner)
|
|
asset=app.save_asset(dict(self.assets[self.aid],siteId=child['id']),self.owner)
|
|
camera=app.get_object('cameras',self.cameras[self.aid]['id'])
|
|
member=app.get_object('users',self.members[self.aid]['id'])
|
|
with self.assertRaises(app.Problem):app.ACCOUNTS.camera(member,camera)
|
|
app.save_object('users',dict(member,includeSubspaces=True))
|
|
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),1)
|
|
# Moving the region to another branch immediately removes inherited access.
|
|
app.save_site(dict(child,parentId=''),self.owner)
|
|
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),0)
|
|
self.assertEqual(app.get_object('assets',asset['id'])['siteId'],child['id'])
|
|
|
|
def test_generic_devices_responsibilities_and_cross_family_access(self):
|
|
data=dict(name='卧室台灯',deviceType='灯',siteId=self.sites[self.aid]['id'],ownerName='家人甲',managerName='家人乙',tags=['照明','常用'])
|
|
code,device=self.request('/api/devices',data=data)
|
|
self.assertEqual(code,200);self.assertEqual(device['managerName'],'家人乙')
|
|
self.assertEqual([d['id'] for d in self.request('/api/state')[1]['devices']],[device['id']])
|
|
self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
|
|
self.assertIn(self.request('/api/devices',self.other,dict(device,name='hijack'))[0],(403,404))
|
|
self.assertEqual(self.request('/api/devices',data=dict(data,siteId=self.sites[self.bid]['id']))[0],404)
|
|
self.assertEqual(self.request('/api/devices',self.members[self.aid],data)[0],403)
|
|
for invalid in (dict(data,tags='tag'),dict(data,deviceType='录像机'),dict(data,tags=['x']*13)):
|
|
self.assertEqual(self.request('/api/devices',data=invalid)[0],400)
|
|
|
|
def test_video_and_other_devices_share_responsibility_metadata(self):
|
|
body=dict(self.assets[self.aid],ownerName='设备所有人',managerName='设备负责人',tags=['视频'])
|
|
asset=app.save_asset(body,self.owner)
|
|
recorder=app.save_recorder(dict(name='Recorder',siteId=self.sites[self.aid]['id'],host='192.168.3.90',ownerName='设备所有人',managerName='设备负责人',tags=['视频']),self.owner)
|
|
self.assertEqual(asset['managerName'],recorder['managerName'])
|
|
self.assertEqual(asset['tags'],recorder['tags'])
|
|
body.pop('ownerName');body.pop('managerName');body.pop('tags')
|
|
self.assertEqual(app.save_asset(body,self.owner)['managerName'],'设备负责人')
|
|
|
|
|
|
if __name__=='__main__': unittest.main()
|