Files
zyt/server/tests/IamV2SourceTest.php
T

204 lines
25 KiB
PHP

<?php
/** Disposable SQLite only: no .env, provider calls or real identities. */
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
require dirname(__DIR__) . '/app/common.php';
use think\facade\Db;
use app\adminapi\service\iam\v2\{IamV2Source, IamV2Flow, IamV2Client, IamV2Manager, IamV2Events};
function ok(bool $x, string $m): void { if (!$x) { throw new RuntimeException('FAIL ' . $m); } }
function denies(callable $fn, string $message): void { try { $fn(); } catch (RuntimeException $e) { ok($e->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";