Files
zyt/server/tests/IamV2TargetTest.php
T

57 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/app/common.php';
use think\facade\Db;
use app\adminapi\service\iam\v2\{IamV2Client,IamV2Target,IamV2Source,IamV2Manager};
function targetOK(bool $ok,string $message):void { if(!$ok)throw new RuntimeException('FAIL '.$message); }
function targetReject(callable $fn,string $code):void { try{$fn();}catch(RuntimeException $e){targetOK($e->getMessage()===$code,$code.' got '.$e->getMessage());return;}throw new RuntimeException('accepted '.$code); }
$app=new think\App(dirname(__DIR__).'/');think\Container::setInstance($app);$file=tempnam(sys_get_temp_dir(),'zyt-target-');
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]],'database');
$db=new think\DbManager();$db->setConfig($app->config->get('database'));$app->instance('think\Db',$db);
$config=['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','application_id'=>'zyt','v2_token'=>str_repeat('v',32),
'v2_sources'=>['iam-admin'=>'https://iam.example.test/admin/iam/launch/continue','iam-portal'=>'https://iam.example.test/portal/launch/continue']];$app->config->set($config,'iam');
try {
$ini=parse_ini_string('[IAM]'.PHP_EOL.'V2_SOURCES_JSON = '.json_encode($config['v2_sources']),true,INI_SCANNER_RAW);
targetOK(json_decode($ini['IAM']['V2_SOURCES_JSON'],true)===$config['v2_sources'],'documented fixed source mapping parses through ThinkPHP INI scanner');
foreach([
'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,delete_time INTEGER)',
'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,token TEXT,expire_time INTEGER)',
'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_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_target_session (token_hash TEXT PRIMARY KEY,admin_id INTEGER,employee_id TEXT,source_application_id TEXT,source_binding_version INTEGER,checked_at INTEGER)'
]as$sql)Db::execute($sql);
Db::name('admin')->insertAll([['id'=>1,'account'=>'other','name'=>'Other','password'=>'synthetic','disable'=>0,'root'=>0],['id'=>2,'account'=>'employee','name'=>'Employee','password'=>'synthetic','disable'=>0,'root'=>0]]);
Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'token'=>'token-other','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'token'=>'token-matching','expire_time'=>time()+3600]]);
$count=0;$tx=[];$evil=false;$exchangeCalls=0;$resolveCalls=0;$loginCalls=0;$source='iam-admin';$authTime=time()-20;
$employee=['id'=>'emp-fixture','status'=>'active','oidcSubject'=>'subject-fixture'];$binding=['id'=>'binding','applicationId'=>'zyt','externalAccountId'=>'2','employeeId'=>'emp-fixture','state'=>'verified','version'=>1];
$client=new IamV2Client($config,function($method,$url,$headers,$body)use(&$count,&$tx,&$evil,&$exchangeCalls,&$source,$employee,$binding,$authTime){
$b=json_decode($body,true);
if(str_ends_with($url,'/launch-transactions')){$id='tx-'.(++$count);$source=$b['sourceApplicationId'];$tx[$id]=$b;return ['status'=>200,'body'=>json_encode(['transactionId'=>$id,'sourceContinueUrl'=>($evil?'https://evil.example.test': 'https://iam.example.test').($source==='iam-admin'?'/admin/iam/launch/continue':'/portal/launch/continue').'?transaction_id='.$id,'expiresAt'=>gmdate(DATE_RFC3339,time()+300)])];}
if(str_ends_with($url,'/launch-grants/exchange')){$exchangeCalls++;targetOK(rtrim(strtr(base64_encode(hash('sha256',$b['codeVerifier'],true)),'+/','-_'),'=')===$tx[$b['transactionId']]['codeChallenge'],'target PKCE is consistent');return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true],'authentication'=>['sourceApplicationId'=>$source,'sessionId'=>'native-source-session','sessionVersion'=>1,'authMethod'=>'password','authTime'=>gmdate(DATE_RFC3339,$authTime),'sourceBindingVersion'=>0]])];}
if(str_contains($url,'/bindings/by-external/'))return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true]])];
throw new RuntimeException('UNEXPECTED_NETWORK');
});
$target=new IamV2Target($client,function($result)use(&$resolveCalls){$resolveCalls++;return 2;},function($id,$auth)use(&$loginCalls){$loginCalls++;IamV2Source::enroll('token-matching','iam_launch',false,IamV2Target::authTime($auth['authTime']));return ['token'=>'token-matching','is_paw'=>1,'need_bind_work_wechat'=>false];});
$intent=str_repeat('i',43);targetReject(fn()=>$target->prepare('zmxy-erp',$intent,''),'NOT_AUTHORIZED');targetReject(fn()=>$target->prepare('iam-admin',$intent,'invalid'),'REAUTH_REQUIRED');
$evil=true;targetReject(fn()=>$target->prepare('iam-admin',$intent,''),'INVALID_FIXED_URL');$evil=false;
$start=$target->prepare('iam-admin',$intent,'');parse_str(parse_url($start['url'],PHP_URL_QUERY),$q);targetOK($q['intent']===$intent,'intent preserved in fixed continuation');$id=$q['transaction_id'];
targetReject(fn()=>$target->callback($id,str_repeat('g',43),str_repeat('f',64),'https://iam.example.test'),'ACCOUNT_MISMATCH');
targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://evil.example.test'),'INVALID_ORIGIN');
$ticket=$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test');targetOK($resolveCalls===1,'same-identity resolver called once');
targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test'),'TRANSACTION_EXPIRED');
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-other'),'ACCOUNT_MISMATCH');targetOK($loginCalls===0,'midflow account change does not issue token');
$session=IamV2Source::enroll('token-other','password',false);
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'',$session['session_id']),'ACCOUNT_MISMATCH');
$result=$target->exchange($ticket['ticket'],$start['browser'],'token-matching');targetOK($result['token']==='token-matching'&&$loginCalls===1,'IAM local admin grant without KC cookie establishes target login');
$local=Db::name('iam_v2_source_session')->where('auth_method','iam_launch')->find();targetOK((int)$local['auth_time']===$authTime,'original RFC3339 authentication age preserved');
targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-matching'),'TRANSACTION_EXPIRED');
$other=$target->prepare('iam-portal',$intent,'token-other');parse_str(parse_url($other['url'],PHP_URL_QUERY),$q);
targetReject(fn()=>$target->callback($q['transaction_id'],str_repeat('g',43),$other['browser'],'https://iam.example.test'),'ACCOUNT_MISMATCH');targetOK($resolveCalls===1,'preexisting mismatch rejects before local auto-provision');
$selection=IamV2Manager::retrySelection(['indexes'=>[0,2],'items'=>[['status'=>'succeeded','externalAccountId'=>'1','kind'=>'bind'],['status'=>'failed','externalAccountId'=>'2','kind'=>'bind']]],[1]);
targetOK($selection['indexes']===[2] && $selection['targets']===[2],'job item position translated to original noncontiguous preview index');
targetReject(fn()=>IamV2Target::authTime(gmdate(DATE_RFC3339,time()+120)),'INVALID_AUTHENTICATION');
echo "PASS IamV2TargetTest: native IAM admin/portal to ZYT without KC cookie, strict source allowlist, fixed continuation+intent, PKCE, browser/origin/replay checks, existing and midflow account mismatch, no duplicate provisioning, RFC3339 age, target lineage, noncontiguous retry index\n";
} finally {@unlink($file);}