"""Real HTTP security tests; only the explicitly created loopback synthetic fixture.
Set SECURITY_TEST_BASE_URL, SECURITY_FIXTURE_CONFIG and PHP_BINARY.
Start PHP's local server on the fixture's public_html, never the real checkout.
"""
import base64, hashlib, hmac, http.cookiejar, json, os, struct, subprocess, time
from pathlib import Path
from urllib.request import build_opener, HTTPCookieProcessor, Request
from urllib.error import HTTPError

base=os.environ.get('SECURITY_TEST_BASE_URL','')
assert base=='http://127.0.0.1:8092', 'Explicit isolated loopback server required'
assert 'lodge_security_test_' in os.environ.get('SECURITY_FIXTURE_CONFIG',''), 'Fixture config required'
password='Fixture-password-only-2026'
checks=0
def expect(ok,label):
    global checks
    assert ok,label
    checks+=1
def fixture(action):
    result=subprocess.run([os.environ['PHP_BINARY'],str(Path(__file__).with_name('security_http_fixture.php')),action],capture_output=True,text=True)
    assert result.returncode==0, 'Fixture operation failed: '+action+' '+result.stderr
class Client:
    def __init__(self):
        self.jar=http.cookiejar.CookieJar();self.opener=build_opener(HTTPCookieProcessor(self.jar));self.csrf=''
    def call(self,path,body=None):
        headers={'Accept':'application/json'}
        if body is not None: headers.update({'Content-Type':'application/json','X-CSRF-Token':self.csrf})
        req=Request(base+path,None if body is None else json.dumps(body).encode(),headers)
        try:r=self.opener.open(req,timeout=15)
        except HTTPError as e:r=e
        with r:
            raw=r.read();d=json.loads(raw) if 'application/json' in r.headers.get('Content-Type','') else raw
            if isinstance(d,dict):self.csrf=d.get('csrfToken',d.get('csrf',self.csrf))
            return r.status,d
    def cookie(self):return next(c.value for c in self.jar if c.name=='LodgeSecurityFixture')
    def login(self,user,code='',unit=''):
        self.call('/admin/api/auth/status.php')
        return self.call('/admin/api/auth/login.php',dict(email=f'security{user}@example.test',password=password,twoFactorCode=code,unit=unit))
    def security(self,action,**args):return self.call('/admin/api/auth/security.php',dict(action=action,**args))
def totp(secret):
    raw=base64.b32decode(secret);digest=hmac.new(raw,struct.pack('>Q',int(time.time())//30),hashlib.sha1).digest();offset=digest[-1]&15
    return str((struct.unpack('>I',digest[offset:offset+4])[0]&0x7fffffff)%1000000).zfill(6)

a=Client();a.call('/admin/api/auth/status.php');before=a.cookie()
status,data=a.login(4);expect(status==200 and data.get('user',{}).get('id')==4,'Unit login');expect(a.cookie()!=before,'Login rotates session ID')
other=Client();expect(other.login(4)[0]==200,'Other device login')
status,_=a.call('/admin/api/roles/save.php?unit=test-unit-a',dict(email='security7@example.test',scopeType='unit',role='member',targetId=1,active=True));expect(status==428,'Role grant requires reauthentication')
status,_=a.call('/admin/api/finance.php?unit=test-unit-a',dict(action='bank',issuerName='Fixture issuer',accountName='Fixture bank',sortCode='00-00-00',accountNumber='00000000'));expect(status==428,'Bank details require reauthentication before writes')
status,_=a.security('begin_enrolment');expect(status==428,'Enrolment requires password proof')
before=a.cookie();status,_=a.security('reauthenticate',password=password,code='');expect(status==200,'Initial password verification');expect(a.cookie()!=before,'Reauthentication rotates session ID')
status,d=a.security('begin_enrolment');expect(status==200 and len(d.get('secret',''))==32,'Start enrolment');secret=d['secret']
status,_=a.security('confirm_enrolment',code='bad');expect(status==422,'Wrong enrolment code')
status,d=a.security('confirm_enrolment',code=totp(secret));expect(status==200 and len(d.get('recoveryCodes',[]))==10,'Confirm enrolment');codes=d['recoveryCodes']
expect(other.call('/admin/api/auth/security.php')[0]==401,'Enrolment revokes old sessions')
status,d=a.call('/admin/api/auth/security.php');expect(status==200 and d['enabled'],'MFA status');expect('secret' not in d and 'recoveryCodes' not in d,'Secrets not redisplayed');expect(d['recoveryRemaining']==10,'Recovery count')
fresh=Client();status,d=fresh.login(4);expect(status==200 and d.get('factorType')=='authenticator','Enrolled account requires authenticator');expect(fresh.call('/admin/api/auth/security.php')[0]==401,'No session from password-only phase')
status,_=fresh.login(4,totp(secret));expect(status==401,'Enrolment TOTP cannot be replayed for login')
status,_=fresh.login(4,'wrong');expect(status==401,'Wrong login factor')
status,_=fresh.login(4,codes[0]);expect(status==200,'Recovery sign-in')
replay=Client();expect(replay.login(4,codes[0])[0]==401,'Recovery code cannot be reused')
status,_=fresh.security('reauthenticate',password=password,code=codes[1]);expect(status==200,'Recovery step-up');expect(fresh.security('reauthenticate',password=password,code=codes[1])[0]==422,'Recovery step-up replay')
status,_=fresh.call('/admin/api/roles/save.php?unit=test-unit-a',dict(email='security7@example.test',scopeType='unit',role='member',targetId=1,active=True));expect(status==200,'Scoped role grant after reauthentication')
fixture('permission_events')
status,d=fresh.call('/admin/api/permission-sets/save.php?unit=test-unit-a',dict(scope='event',targetId=9002,name='Event reader',sourceRole='readonly',capabilities=['events.view','meeting.read']));expect(status==200 and int(d.get('id',0))>0,'Create narrowed event permission set');permission_set=int(d['id'])
expect(fresh.call('/admin/api/permission-sets/save.php?unit=test-unit-a',dict(scope='event',targetId=9003,name='Forged other Province',sourceRole='readonly',capabilities=['events.view']))[0] in (403,404),'Unit administrator cannot create a cross-Province set')
expect(fresh.call('/admin/api/permission-sets/grant.php?unit=test-unit-a',dict(scope='event',targetId=9002,email='security4@example.test',permissionSetId=permission_set,active=True))[0] in (403,404),'Self grant denied')
expect(fresh.call('/admin/api/permission-sets/grant.php?unit=test-unit-a',dict(scope='event',targetId=9002,email='security9@example.test',permissionSetId=permission_set,active=True))[0]==200,'Event grant saved')
event_user=Client();expect(event_user.login(9,unit='test-unit-a')[0]==200,'Event-only user can enter event unit');expect(event_user.call('/admin/api/meeting/detail.php?unit=test-unit-a&id=9002')[0]==200,'Event-only positive route');expect(event_user.call('/admin/api/meeting/detail.php?unit=test-unit-b&id=9003')[0] in (403,404),'Forged cross-Province event denied')
status,_=fresh.security('revoke_others');expect(status==200,'Revoke other sessions');expect(a.call('/admin/api/auth/security.php')[0]==401,'Older enrolled session revoked')
fixture('document');status,d=fresh.call('/admin/api/documents/download.php?unit=test-unit-a&id=9001');expect(status==200 and b'Synthetic' in d,'Own unit download')
b=Client();expect(b.login(5)[0]==200,'Second unit login');expect(b.call('/admin/api/documents/download.php?unit=test-unit-a&id=9001')[0]==403,'Other unit download denied')
pa=Client();pb=Client();expect(pa.login(2)[0]==200,'Province-only login');expect(pb.login(3)[0]==200,'Second Province login')
expect(pa.call('/province/api/context.php')[0]==200,'Province context without selected unit')
expect(pa.call('/admin/api/auth/access-review.php?scope=province&scopeId=2')[0]==403,'Cross Province review denied')
expect(fresh.call('/admin/api/auth/access-review.php?scope=province&scopeId=1')[0]==403,'Unit cannot review Province')
status,d=pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1');expect(status==200 and all(r['scope_type']=='unit' for r in d['rows']),'Scoped review list')
member=next(r for r in d['rows'] if int(r['user_id'])==7)
expect(pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1',dict(membershipId=member['id'],decision='retain'))[0]==428,'Review requires reauthentication')
expect(pa.security('reauthenticate',password=password)[0]==200,'Province step-up')
status,_=pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1',dict(membershipId=member['id'],decision='retain',reviewDays=30,expiresAt='2099-01-01 00:00:00'));expect(status==200,'Record review with expiry')
status,d=pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1');expect(any(r['decision']=='retain' for r in d['history']),'Review history')
fixture('pending_invite');expect(pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1',dict(membershipId=member['id'],decision='revoke'))[0]==200,'Revoke reviewed grant');
invited=Client();invited.call('/admin/api/roles/accept.php');expect(invited.call('/admin/api/roles/accept.php',dict(token='c'*64))[0]==404,'Old invitation cannot resurrect revoked grant')
fixture('revoke_unit');expect(fresh.call('/admin/api/documents/download.php?unit=test-unit-a&id=9001')[0]==403,'Revocation immediately denies downloads');expect(fresh.call('/admin/api/auth/security.php')[0]==200,'Revoked officer retains own account security')
fixture('expire_province');expect(pa.call('/province/api/context.php')[0]==403,'Expired Province grant denied');expect(pa.call('/admin/api/auth/access-review.php?scope=unit&scopeId=1')[0]==403,'Expired reviewer denied')
fixture('expire_session');expect(b.call('/admin/api/auth/security.php')[0]==401,'Absolute session expiry')
member_client=Client();expect(member_client.login(7)[0]==200,'Member login');fixture('idle_session');expect(member_client.call('/admin/api/auth/security.php')[0]==401,'Idle expiry')
platform=Client();expect(platform.login(1)[0]==200,'Platform login');status,d=platform.call('/admin/api/auth/access-review.php?scope=province&scopeId=2');expect(status==200 and 'welfare' not in json.dumps(d).lower(),'Platform grant review without welfare content')
fixture('queue_check')
fixture('reset_token');reset=Client();reset.call('/admin/api/auth/status.php');status,_=reset.call('/admin/api/auth/reset-confirm.php',dict(selector='a'*16,token='b'*64,password=password));expect(status==200,'Password reset completes');expect(fresh.call('/admin/api/auth/security.php')[0]==401,'Reset invalidates existing session')
after_reset=Client();status,d=after_reset.login(4);expect(status==200 and d.get('factorType')=='authenticator','Password reset cannot remove MFA');status,d=after_reset.login(4,codes[2]);expect(status==200 and d.get('securityOnly'),'Revoked account can recover only its own account security')
expect(after_reset.call('/admin/api/auth/status.php')[0]==200,'Account status works without active grants')
expect(after_reset.call('/admin/api/auth/logout.php',{})[0]==200,'Account without grants can sign out')
print(f'HTTP security: {checks} assertions passed; revoked queue delivery also suppressed. No email was delivered.')
