getMessage() === $message, $message . ' got ' . $e->getMessage()); return; } throw new RuntimeException('accepted ' . $message); } $app = new think\App(dirname(__DIR__) . '/'); think\Container::setInstance($app); $file = tempnam(sys_get_temp_dir(), 'zyt-iamv2-'); $cachePath = $file . '-cache/'; $app->config->set(['default'=>'file','stores'=>['file'=>['type'=>'File','path'=>$cachePath]]], 'cache'); $app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]], 'database'); $manager = new think\DbManager(); $manager->setConfig($app->config->get('database')); $app->instance('think\Db', $manager); $app->config->set(['data_scope'=>['enabled'=>false], 'unique_identification'=>'fixture'], 'project'); $app->config->set(['application_id'=>'zyt','portal_url'=>'https://iam.example.test','v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'v2_introspection_token'=>str_repeat('c',32)], 'iam'); try { foreach ([ 'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,multipoint_login INTEGER DEFAULT 1,delete_time INTEGER)', 'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,terminal INTEGER,token TEXT,expire_time INTEGER,update_time INTEGER)', 'CREATE TABLE dept (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)', 'CREATE TABLE admin_dept (admin_id INTEGER,dept_id INTEGER)', 'CREATE TABLE admin_role (admin_id INTEGER,role_id INTEGER)', 'CREATE TABLE system_role (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)', 'CREATE TABLE system_role_menu (role_id INTEGER,menu_id INTEGER)', 'CREATE TABLE system_menu (id INTEGER PRIMARY KEY,is_disable INTEGER,perms TEXT)', 'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,binding_version INTEGER,employee_id TEXT,expires_at INTEGER)', 'CREATE TABLE iam_v2_proof (proof_hash TEXT PRIMARY KEY,purpose TEXT,session_id TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)', 'CREATE TABLE iam_v2_flow (flow_key TEXT PRIMARY KEY,kind TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)', 'CREATE TABLE iam_v2_binding (admin_id INTEGER PRIMARY KEY,employee_id TEXT,subject TEXT,state TEXT,version INTEGER,payload TEXT,checked_at INTEGER)', 'CREATE TABLE iam_v2_event (event_id TEXT PRIMARY KEY, digest TEXT, received_at INTEGER)', 'CREATE TABLE iam_v2_resource (resource_key TEXT PRIMARY KEY,kind TEXT,external_id TEXT,actor_id INTEGER,payload TEXT,created_at INTEGER)', ] as $sql) { Db::execute($sql); } Db::name('admin')->insertAll([['id'=>1,'account'=>'operator','name'=>'Synthetic Operator','password'=>'not-real','disable'=>0,'root'=>1],['id'=>2,'account'=>'employee','name'=>'Synthetic Employee','password'=>'not-real','disable'=>0,'root'=>0]]); Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'terminal'=>1,'token'=>'fixture-token-1','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'terminal'=>1,'token'=>'fixture-token-2','expire_time'=>time()+3600]]); denies(fn()=>IamV2Source::current('fixture-token-1'),'REAUTH_REQUIRED'); $s1=IamV2Source::enroll('fixture-token-1','password',false); $s2=IamV2Source::enroll('fixture-token-1','password',false); ok($s1['session_id']!==$s2['session_id'],'independent browser sessions despite same business token'); $_COOKIE[IamV2Source::COOKIE]=$s1['session_id']; ok(IamV2Source::current('fixture-token-1')['auth_method']==='password','immutable method'); denies(fn()=>IamV2Source::current('fixture-token-2'),'ACCOUNT_MISMATCH'); denies(fn()=>IamV2Source::enroll('fixture-token-1','legacy',false),'REAUTH_REQUIRED'); IamV2Source::authorize($s1,'bind',[2]); denies(fn()=>IamV2Source::authorize($s1,'bind',[1]),'PROTECTED_ACCOUNT_REVIEW_REQUIRED'); $proof=IamV2Source::proof($s1,'launch',[1],[],'tx-fixture'); ok(!str_contains(json_encode(Db::name('iam_v2_proof')->select()->toArray()),$proof),'proof plaintext never stored'); denies(fn()=>IamV2Source::introspect($proof,'management','tx-fixture'),'TRANSACTION_EXPIRED'); denies(fn()=>IamV2Source::introspect($proof,'launch','other'),'ACCOUNT_MISMATCH'); $claims=IamV2Source::introspect($proof,'launch','tx-fixture'); ok($claims['active'] && $claims['actorLocalId']==='1' && !isset($claims['token']),'service claims no business token'); denies(fn()=>IamV2Source::introspect($proof,'launch','tx-fixture'),'TRANSACTION_EXPIRED'); IamV2Flow::put('launch','intent',['sessionId'=>$s1['session_id']]); denies(fn()=>IamV2Flow::take('launch','intent',$s2['session_id']),'ACCOUNT_MISMATCH'); IamV2Flow::take('launch','intent',$s1['session_id']); denies(fn()=>IamV2Flow::take('launch','intent',$s1['session_id']),'TRANSACTION_EXPIRED'); $expired=IamV2Source::proof($s1,'launch'); Db::name('iam_v2_proof')->where('proof_hash',hash('sha256',$expired))->update(['expires_at'=>time()-1]); denies(fn()=>IamV2Source::introspect($expired,'launch'),'TRANSACTION_EXPIRED'); $oldAuth=$s1['auth_time']; Db::name('admin_session')->where('id',1)->update(['update_time'=>time()+30,'expire_time'=>time()+7200]); ok(IamV2Source::check($s1['session_id'])['auth_time']===$oldAuth,'business renewal never freshens authentication'); Db::name('admin')->where('id',1)->update(['disable'=>1]); denies(fn()=>IamV2Source::check($s1['session_id']),'ACCOUNT_DISABLED'); Db::name('admin')->where('id',1)->update(['disable'=>0]); Db::name('admin_session')->where('id',1)->update(['token'=>'rotated']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); Db::name('admin_session')->where('id',1)->update(['token'=>'fixture-token-1']); IamV2Source::revokeSession($s1['session_id']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); ok(IamV2Source::check($s2['session_id'])['revoked']===0,'revoking one browser preserves other independent session'); $calls=[];$cfg=['v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'v2_management_token'=>str_repeat('m',32),'application_id'=>'zyt']; $client=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$calls){$calls[]=compact('method','url','headers','body');return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])];}); $service=new IamV2Manager($client);$service->binding(2);$service->binding(2);ok(count($calls)===1,'30 second read cache'); Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>time()-31]);$service->binding(2);ok(count($calls)===2,'missed revocation compensation after 30 seconds'); Db::name('dept')->insertAll([['id'=>10,'name'=>'Synthetic A','delete_time'=>null],['id'=>20,'name'=>'Synthetic B','delete_time'=>null],['id'=>30,'name'=>'Deleted Department','delete_time'=>time()]]); Db::name('admin_dept')->insertAll([['admin_id'=>1,'dept_id'=>10],['admin_id'=>1,'dept_id'=>20],['admin_id'=>1,'dept_id'=>30],['admin_id'=>2,'dept_id'=>20]]); $checkedAt=time()-5;Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>$checkedAt]); $listing=$service->accounts(['q'=>'employee'],1);ok(count($listing['items'])===1 && $listing['items'][0]['dept_names']===['Synthetic B'],'department labels for scoped account'); ok($listing['items'][0]['iam']['checked_at']===$checkedAt,'cached successful check time is returned unchanged'); $all=$service->accounts([],1);$byId=[];foreach($all['items']as$item)$byId[$item['id']]=$item; ok($byId[1]['dept_names']===['Synthetic A','Synthetic B'],'soft-deleted department is not displayed'); $project=$app->config->get('project');$scoped=$project;$scoped['data_scope']['enabled']=true;$app->config->set($scoped,'project'); $self=$service->accounts([],2);ok(count($self['items'])===1 && (int)$self['items'][0]['id']===2,'metadata does not widen existing data scope');$app->config->set($project,'project'); $lastSuccess=time()-100;Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>$lastSuccess]); $offline=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>503,'body'=>json_encode(['error'=>['code'=>'SERVICE_UNAVAILABLE']])])); $unknown=$offline->accounts(['q'=>'employee'],1)['items'][0]['iam']; ok($unknown['state']==='unknown' && $unknown['access']['allowed']===null && $unknown['checked_at']===$lastSuccess,'failed refresh never fabricates allowed access or successful timestamp'); ok((int)Db::name('iam_v2_binding')->where('admin_id',2)->value('checked_at')===$lastSuccess,'failure preserves actual persisted check time'); $invokeCallback=function($action,$body,$bearer=null,$method='POST')use($app){ $request=(new think\Request())->withHeader(['authorization'=>$bearer??('Bearer '.str_repeat('c',32))])->withPost($body); $request->setMethod($method);$request->setAction($action);$request->setController('IamV2');$app->instance('request',$request); return (new app\adminapi\controller\IamV2Controller($app))->{$action}(); }; $request=(new think\Request())->withPost([]);$request->setMethod('POST');$request->setAction('launchStart');$app->instance('request',$request); $controller=new app\adminapi\controller\IamV2Controller($app);$method=new ReflectionMethod($controller,'invokeV2');$method->setAccessible(true); $response=$method->invoke($controller,static function(){throw new RuntimeException('NOT_LINKED');});$envelope=$response->getData(); ok($envelope['code']===0 && $envelope['show']===0 && $envelope['data']['error']['code']==='NOT_LINKED','IAM structured failure silences only the global toast'); $live=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1]);$liveBody=$live->getData(); ok($live->getCode()===200 && $liveBody['active'] && $liveBody['sessionId']===$s2['session_id'] && $liveBody['sessionVersion']===1,'actual source callback raw HTTP shape/SID/version'); ok(!isset($liveBody['code']) && !isset($liveBody['data']),'callback is not business response envelope'); $managed=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1,'purpose'=>'management','targetLocalIds'=>['2']]); ok($managed->getData()['targetLocalIds']===['2'] && in_array('iam:bind',$managed->getData()['capabilities'],true),'live callback returns current management capabilities and scope'); $invalid=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1],'Bearer wrong');ok($invalid->getCode()===403 && !$invalid->getData()['active'],'callback dedicated credential required'); $wireHTTP=IamV2Source::proof($s2,'management',[2],['iam:bind']); $actual=$invokeCallback('introspect',['proof'=>$wireHTTP,'purpose'=>'management','transactionId'=>'']);ok($actual->getCode()===200 && $actual->getData()['active'],'Go HTTP attestor body reaches actual controller'); $replay=$invokeCallback('introspect',['proof'=>$wireHTTP,'purpose'=>'management','transactionId'=>'']);ok($replay->getCode()===403,'actual callback replay fails'); $wire=IamV2Source::proof($s2,'management',[2],['iam:bind']); ok(IamV2Source::introspect($wire,'management','')['active'],'Go callback empty transactionId matches absent transaction'); ok(IamV2Manager::cap('reconcile')==='bind','central reconcile capability contract'); $normalSession=IamV2Source::enroll('fixture-token-2','password',false); $binding=['binding'=>['applicationId'=>'zyt','employeeId'=>'employee-fixture','state'=>'verified','version'=>2,'externalAccountId'=>'2'],'employee'=>['id'=>'employee-fixture','status'=>'active','oidcSubject'=>'subject-fixture'],'access'=>['allowed'=>true]]; $firstLinkService=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>200,'body'=>json_encode($binding)])); $firstLinkService->binding(2,true); ok(IamV2Source::check($normalSession['session_id'])['revoked']===0,'first association preserves authenticated local session'); Db::name('iam_v2_binding')->where('admin_id',2)->update(['employee_id'=>'employee-fixture','state'=>'verified','version'=>2,'payload'=>json_encode($binding)]); $secret=str_repeat('e',32);$revoked=[]; $event=['eventType'=>'binding_revoked','id'=>'evt-fixture','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'revoked','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z'),'bindingVersion'=>1]; $raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret); denies(fn()=>IamV2Events::accept($raw,'invalid','evt-fixture',$secret),'FORBIDDEN'); denies(fn()=>IamV2Events::accept($raw,$sig,'another-event',$secret),'INVALID_EVENT'); $revoke=function($id)use(&$revoked){$revoked[]=$id;}; IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke,null,null,fn($id,$version)=>null);ok($revoked===[],'older event cannot revoke newer binding'); ok(IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke)['duplicate'],'signed event idempotence'); $event['id']='evt-new';$event['bindingVersion']=3;$raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret); IamV2Events::accept($raw,$sig,'evt-new',$secret,$revoke);ok($revoked===[2],'valid event revokes exact identity'); $event['reason']='changed';$other=json_encode($event);denies(fn()=>IamV2Events::accept($other,'sha256='.hash_hmac('sha256',$other,$secret),'evt-new',$secret,$revoke),'INVALID_EVENT'); $event['id']='future';$event['occurredAt']=gmdate('Y-m-d\TH:i:s\Z',time()+120);$future=json_encode($event);denies(fn()=>IamV2Events::accept($future,'sha256='.hash_hmac('sha256',$future,$secret),'future',$secret,$revoke),'TRANSACTION_EXPIRED'); $binding['binding']['version']=3; $stale=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>200,'body'=>json_encode($binding)])); denies(fn()=>$stale->binding(2,true),'BINDING_CONFLICT'); Db::name('iam_v2_binding')->where('admin_id',2)->update(['state'=>'verified','version'=>3]); $statusEvent=['id'=>'evt-disabled','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'employee.disabled','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z')]; $statusRaw=json_encode($statusEvent);IamV2Events::accept($statusRaw,'sha256='.hash_hmac('sha256',$statusRaw,$secret),'evt-disabled',$secret,$revoke,fn()=>['binding'=>['state'=>'verified'],'employee'=>['status'=>'disabled'],'access'=>['allowed'=>false]]); ok(Db::name('iam_v2_binding')->where('admin_id',2)->value('state')==='verified','personnel/access revoke never creates permanent binding tombstone'); $stale->binding(2,true); // restored access at same binding version is recoverable. $late=$statusEvent;$late['id']='evt-delayed';$late['occurredAt']=gmdate('Y-m-d\TH:i:s\Z',time()-86400);$lateRaw=json_encode($late);$revocationsBefore=count($revoked); IamV2Events::accept($lateRaw,'sha256='.hash_hmac('sha256',$lateRaw,$secret),'evt-delayed',$secret,$revoke,fn()=>['binding'=>['state'=>'verified'],'employee'=>['status'=>'active'],'access'=>['allowed'=>true]]); ok(count($revoked)===$revocationsBefore,'old durable generic event accepted without revoking reauthorized sessions'); $beforeUsers=Db::name('admin')->select()->toArray();$batchCalls=[]; $batchClient=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$batchCalls){ $data=json_decode($body,true);$batchCalls[]=$data; $claims=IamV2Source::introspect($data['proof'],'management'); ok($claims['targetLocalIds']===['2'],'only authorized safe target in central proof'); ok(in_array('iam:bind',$claims['capabilities'],true),'explicit binding capability'); if(str_ends_with($url,'/batch/preflight'))return ['status'=>200,'body'=>json_encode(['previewId'=>'preview-fixture','items'=>[['index'=>0,'externalAccountId'=>'2','status'=>'ready']],'expiresAt'=>time()+300])]; ok($data['indexes']===[0],'original selected index mapped to central filtered index'); return ['status'=>200,'body'=>json_encode(['id'=>'job-fixture','status'=>'pending','items'=>[]])]; }); $batchService=new IamV2Manager($batchClient); $preview=$batchService->preflight($s2,[['kind'=>'bind','user_id'=>1,'employee_id'=>'protected','reason'=>'fixture'],['kind'=>'bind','user_id'=>2,'employee_id'=>'normal','reason'=>'fixture']]); ok($preview['items'][0]['status']==='review' && $preview['items'][1]['index']===1,'bulk protected skipped, original indexes retained'); denies(fn()=>$batchService->batch($s2,'preview-fixture',[0],'fixture-key'),'INVALID_INPUT'); $job=$batchService->batch($s2,'preview-fixture',[1],'fixture-key');ok($job['id']==='job-fixture','explicit safe selection starts job'); ok(Db::name('admin')->select()->toArray()===$beforeUsers,'preflight/job orchestration never changes local accounts/roles'); denies(fn()=>IamV2Manager::input(['kind'=>'rebind','user_id'=>2,'reason'=>'fixture']),'BINDING_CONFLICT'); denies(fn()=>IamV2Manager::input(['kind'=>'create_and_bind','user_id'=>2,'reason'=>'fixture','new_employee'=>['username'=>'invalid /','display_name'=>'Name']]),'INVALID_INPUT'); $newerSession=IamV2Source::enroll('fixture-token-2','password',false); IamV2Source::revokeGeneration(2,2); ok(IamV2Source::check($newerSession['session_id'])['binding_version']===3,'late older binding generation preserves newer source SID and shared business token'); $normalSession=$newerSession; $reviewClient=new IamV2Client($cfg,function($method,$url,$headers,$body){ if($method==='GET')return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])]; $data=json_decode($body,true);$claim=IamV2Source::introspect($data['proof'],'management',''); ok($claim['protectedAccount']===true,'single protected source request remains marked for central review'); return ['status'=>200,'body'=>json_encode(['id'=>'review-fixture','status'=>'pending','error'=>['code'=>'PROTECTED_ACCOUNT_REVIEW_REQUIRED'],'reviewUrl'=>'https://iam.example.test/admin/iam/review?request=review-fixture'])]; }); $review=(new IamV2Manager($reviewClient))->operation($s2,['kind'=>'bind','user_id'=>1,'employee_id'=>'employee-admin','reason'=>'verified fixture'],'stable-review-key'); ok($review['status']==='pending' && isset($review['reviewUrl']),'root single operation reaches central pending review, not completion'); $raceProof=IamV2Source::proof($s2,'management',[2],['iam:bind'],null,['protectedReviewEligible'=>true]); Db::name('system_menu')->insert(['id'=>7,'is_disable'=>0,'perms'=>'iamV2/bind']); Db::name('system_role_menu')->insert(['role_id'=>7,'menu_id'=>7]);Db::name('admin_role')->insert(['admin_id'=>2,'role_id'=>7]); ok(IamV2Source::introspect($raceProof,'management','')['protectedAccount']===true,'protection recomputed at callback after target privilege change'); denies(fn()=>IamV2Source::authorize($normalSession,'bind',[1],true,true),'PROTECTED_ACCOUNT_REVIEW_REQUIRED'); ok(IamV2Source::liveStatus($normalSession['session_id'],1,'self_link')['protectedAccount']===true,'self-link callback reports live protection without requiring management capabilities'); denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['1']),'PROTECTED_ACCOUNT_REVIEW_REQUIRED'); Db::name('system_role_menu')->where('role_id',7)->delete(); denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['2']),'FORBIDDEN'); Db::name('admin')->where('id',1)->update(['password'=>'new-credential']); denies(fn()=>IamV2Source::check($s2['session_id']),'REAUTH_REQUIRED'); ok((int) Db::name('iam_v2_source_session')->where('session_id',$s2['session_id'])->value('version')===2,'credential change increments source revocation version'); $managementMissing=$cfg;unset($managementMissing['v2_management_token']); denies(fn()=>(new IamV2Client($managementMissing))->call('GET','/identities'),'SERVICE_UNAVAILABLE'); $credentialCalls=[];$credentials=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$credentialCalls){$credentialCalls[]=$headers;return ['status'=>200,'body'=>'{}'];}); $credentials->call('GET','/identities');$credentials->call('GET','/bindings/by-external/2'); ok(in_array('Authorization: Bearer '.str_repeat('m',32),$credentialCalls[0],true) && in_array('Authorization: Bearer '.str_repeat('v',32),$credentialCalls[1],true),'management and login credentials strictly separated'); Db::name('iam_v2_binding')->where('admin_id',2)->delete(); $missing=$statusEvent;$missing['id']='evt-history';$missing['eventType']='binding_revoked';$missing['bindingVersion']=3;$missing['externalAccountId']='2';$missingRaw=json_encode($missing);$generations=[]; $refreshHistory=function($id){$row=['admin_id'=>$id,'employee_id'=>'new-employee','subject'=>'new-subject','state'=>'verified','version'=>4,'payload'=>'{}','checked_at'=>time()];Db::name('iam_v2_binding')->insert($row);return ['binding'=>['state'=>'verified','version'=>4],'employee'=>['id'=>'new-employee','status'=>'active'],'access'=>['allowed'=>true]];}; IamV2Events::accept($missingRaw,'sha256='.hash_hmac('sha256',$missingRaw,$secret),'evt-history',$secret,$revoke,$refreshHistory,fn($event)=>[(int)$event['externalAccountId']],function($id,$upper)use(&$generations){$generations[]=[$id,$upper];}); ok($generations===[[2,3]] && Db::name('iam_v2_binding')->where('admin_id',2)->value('state')==='verified','historical unmapped rebind locates external ID without overwriting new generation'); Db::name('admin')->insert(['id'=>4,'account'=>'old-generation','name'=>'Old generation','password'=>'fixture','disable'=>0,'root'=>0]); Db::name('admin_session')->insert(['id'=>4,'admin_id'=>4,'terminal'=>1,'token'=>'old-generation-token','expire_time'=>time()+3600]); $oldGeneration=IamV2Source::enroll('old-generation-token','password',false); IamV2Source::revokeGeneration(4,1,'old-employee'); ok((int) Db::name('admin_session')->where('id',4)->value('expire_time')<=time(),'old generation without newer holder expires actual business session row'); $gateSession=IamV2Source::enroll('fixture-token-1','password',false); $pendingProof=IamV2Source::proof($gateSession,'management',[2],['iam:bind']); $gateConfig=$app->config->get('iam');$gateConfig['v2_management_enabled']=false;$gateConfig['v2_launch_enabled']=false;$app->config->set($gateConfig,'iam'); ok(IamV2Source::liveStatus($gateSession['session_id'],1)['active'],'closing new-flow gates preserves live-session validation'); denies(fn()=>IamV2Source::proof($gateSession,'launch',[1]),'FEATURE_DISABLED'); denies(fn()=>IamV2Source::introspect($pendingProof,'management',''),'FEATURE_DISABLED'); denies(fn()=>IamV2Source::liveStatus($gateSession['session_id'],1,'management',['2']),'FEATURE_DISABLED'); Db::name('admin')->where('id',1)->update(['disable'=>1]);denies(fn()=>IamV2Source::liveStatus($gateSession['session_id'],1),'ACCOUNT_DISABLED'); ok(!IamV2Client::feature('management',['v2_enabled'=>true]) && !IamV2Client::feature('launch',['v2_enabled'=>true]),'new-flow gates default closed independently of global core'); $legacy=$cfg; unset($legacy['v2_token']); $legacy['application_token']=str_repeat('l',32); denies(fn()=>new IamV2Client($legacy),'SERVICE_UNAVAILABLE'); denies(fn()=>IamV2Client::https('https://iam.example.test/path?override=1'),'INVALID_FIXED_URL'); } finally { @unlink($file); if (is_dir($cachePath)) { foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $path) { $path->isDir() ? rmdir($path->getPathname()) : unlink($path->getPathname()); } rmdir($cachePath); } } echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation, central empty-tx/reconcile contract, first-link/session preservation, access restoration, protected single-review/ordinary denial/live target protection, separate credentials, durable events, historical mapping, generation-specific business expiry, maintenance gates retain revocation core and live self-link protection, scoped departments/real cached check time/unknown access, single-toast envelope\n";