feat: isolate households and add explicit ownership succession

This commit is contained in:
Codex
2026-10-04 10:43:17 +08:00
parent a3f4544ddf
commit 72a3252e25
15 changed files with 635 additions and 60 deletions
+211
View File
@@ -0,0 +1,211 @@
"""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')}, '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')])
if __name__=='__main__': unittest.main()