134 lines
9.0 KiB
PHP
134 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\adminapi\service\iam\IamAccountProvisioner;
|
|
use app\adminapi\service\iam\IamOidcClient;
|
|
use think\facade\Db;
|
|
|
|
require dirname(__DIR__) . '/app/common.php';
|
|
function expectProvision(bool $ok, string $label): void { if (!$ok) { throw new RuntimeException('FAIL: ' . $label); } }
|
|
function rejectProvision(callable $call, string $label): void {
|
|
try { $call(); } catch (Throwable $e) { return; }
|
|
throw new RuntimeException('FAIL: accepted ' . $label);
|
|
}
|
|
$app = new think\App(dirname(__DIR__) . '/');
|
|
think\Container::setInstance($app);
|
|
$file = tempnam(sys_get_temp_dir(), 'iam-provision-');
|
|
$connection = getenv('IAM_TEST_MYSQL') === '1'
|
|
? ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 33316, 'database' => 'iam_fixture', 'username' => 'root', 'password' => 'fixture-only', 'prefix' => '', 'charset' => 'utf8mb4', 'fields_strict' => true]
|
|
: ['type' => 'sqlite', 'database' => $file, 'prefix' => '', 'fields_strict' => true];
|
|
$app->config->set(['default' => 'test', 'connections' => ['test' => $connection]], 'database');
|
|
$manager = new think\DbManager();
|
|
$manager->setConfig($app->config->get('database'));
|
|
$app->instance('think\Db', $manager);
|
|
$app->config->set(['unique_identification' => 'test-only'], 'project');
|
|
if (($argv[1] ?? '') === '--worker') {
|
|
for ($attempt = 0; $attempt < 8; $attempt++) {
|
|
try {
|
|
echo (new IamAccountProvisioner())->createOrResume('concurrent-fixture', ['employee' => ['displayName' => 'Concurrent']]);
|
|
@unlink($file);
|
|
exit(0);
|
|
} catch (Throwable $e) { if ($attempt === 7) { throw $e; } usleep(100000); }
|
|
}
|
|
}
|
|
function schemaProvision(string $sql): void {
|
|
if (getenv('IAM_TEST_MYSQL') === '1') {
|
|
$sql = str_replace(['identity_key TEXT PRIMARY KEY', 'INTEGER PRIMARY KEY AUTOINCREMENT', 'account TEXT UNIQUE'], ['identity_key VARCHAR(64) PRIMARY KEY', 'INTEGER PRIMARY KEY AUTO_INCREMENT', 'account VARCHAR(32) UNIQUE'], $sql);
|
|
}
|
|
Db::execute($sql);
|
|
}
|
|
try {
|
|
if (getenv('IAM_TEST_MYSQL') === '1') {
|
|
foreach (['iam_local_identity', 'admin_role', 'admin', 'system_role'] as $table) { Db::execute('DROP TABLE IF EXISTS ' . $table); }
|
|
}
|
|
schemaProvision('CREATE TABLE iam_local_identity (identity_key TEXT PRIMARY KEY, identity_value TEXT, admin_id INTEGER UNIQUE, create_time INTEGER)');
|
|
schemaProvision('CREATE TABLE system_role (id INTEGER PRIMARY KEY, name TEXT, delete_time INTEGER, disable INTEGER DEFAULT 0)');
|
|
schemaProvision('CREATE TABLE admin (id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT UNIQUE, name TEXT, password TEXT, avatar TEXT, root INTEGER, disable INTEGER, is_paw INTEGER, multipoint_login INTEGER, gender INTEGER, enable_image_consult INTEGER, enable_video_consult INTEGER, enable_charge INTEGER, create_time INTEGER, update_time INTEGER, delete_time INTEGER)');
|
|
schemaProvision('CREATE TABLE admin_role (admin_id INTEGER, role_id INTEGER, UNIQUE(admin_id, role_id))');
|
|
Db::name('system_role')->insert(['id' => 2, 'name' => '医助']);
|
|
Db::name('admin')->insert(['id' => 42, 'account' => 'legacy', 'name' => 'Same Name', 'root' => 0, 'disable' => 0]);
|
|
Db::name('admin_role')->insert(['admin_id' => 42, 'role_id' => 7]);
|
|
$config = ['issuer' => 'https://iam.example.test', 'client_id' => 'zyt', 'client_secret' => 'secret',
|
|
'redirect_uri' => 'https://zyt.example.test/adminapi/iam/callback', 'api_url' => 'https://iam.example.test',
|
|
'application_id' => 'zyt', 'application_token' => 'application-secret'];
|
|
$context = ['applicationId' => 'zyt', 'localIdentityKey' => 'new-user',
|
|
'employee' => ['oidcSubject' => 'new-user', 'status' => 'active', 'displayName' => 'Same Name'],
|
|
'externalAccountId' => '', 'shouldCreateLocalAccount' => true];
|
|
$calls = []; $fail = false; $confirmMismatch = false; $status = 200;
|
|
$client = new IamOidcClient($config, function ($method, $url, $headers, $body) use (&$context, &$calls, &$fail, &$confirmMismatch, &$status) {
|
|
$calls[] = [$method, $url, $headers, $body];
|
|
expectProvision(in_array('Authorization: Bearer application-secret', $headers, true), 'service authentication');
|
|
if ($method === 'POST') {
|
|
expectProvision(str_ends_with($url, '/applications/zyt/bindings'), 'binding endpoint');
|
|
expectProvision(count(preg_grep('/^Idempotency-Key: zyt-provision-/', $headers)) === 1, 'idempotency key');
|
|
expectProvision(count(preg_grep('/^X-Request-ID: /', $headers)) === 1, 'request id');
|
|
if ($fail) { return ['status' => 503, 'body' => '{}']; }
|
|
$binding = json_decode($body, true);
|
|
expectProvision($binding['employeeSubject'] === $context['localIdentityKey'], 'verified subject');
|
|
$context['externalAccountId'] = $confirmMismatch ? '42' : $binding['externalAccountId'];
|
|
$context['shouldCreateLocalAccount'] = false;
|
|
return ['status' => 201, 'body' => json_encode(['state' => 'verified'])];
|
|
}
|
|
return ['status' => $status, 'body' => json_encode($context)];
|
|
});
|
|
$imports = [];
|
|
$p = new IamAccountProvisioner(function ($id, $name) use (&$imports) { $imports[] = [$id, $name]; });
|
|
$fail = true;
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'binding outage');
|
|
expectProvision(Db::name('admin')->count() === 2 && $imports === [], 'durable single pending account before IM');
|
|
$pending = Db::name('iam_local_identity')->select()->toArray()[0]['admin_id'];
|
|
$fail = false;
|
|
$id = $p->resolve($client, $config, 'new-user');
|
|
expectProvision($imports === [[$id, 'Same Name']], 'IM initialization after confirmation');
|
|
expectProvision($id === (int) $pending && $id !== 42, 'retry same identity and no name merge');
|
|
expectProvision(array_map('intval', Db::name('admin_role')->where('admin_id', $id)->column('role_id')) === [2], 'new default role 2 only');
|
|
$admin = Db::name('admin')->where('id', $id)->find();
|
|
expectProvision((int) $admin['root'] === 0 && (int) $admin['is_paw'] === 0, 'no root or password gate bypass');
|
|
$before = Db::name('admin')->select()->toArray();
|
|
expectProvision($p->resolve($client, $config, 'new-user') === $id && Db::name('admin')->select()->toArray() === $before, 'existing login is read only');
|
|
$context['externalAccountId'] = '42';
|
|
expectProvision($p->resolve($client, $config, 'new-user') === 42, 'explicit legacy binding');
|
|
expectProvision(array_map('intval', Db::name('admin_role')->where('admin_id', 42)->column('role_id')) === [7], 'legacy permissions preserved');
|
|
$status = 403;
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'revoked grant');
|
|
$status = 200;
|
|
$context['employee']['status'] = 'disabled';
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'disabled employee');
|
|
$context['employee']['status'] = 'active';
|
|
$context['shouldCreateLocalAccount'] = true; $context['externalAccountId'] = '';
|
|
$confirmMismatch = true;
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'confirmation wrong ID');
|
|
$context['shouldCreateLocalAccount'] = true; $context['externalAccountId'] = '';
|
|
Db::name('admin')->where('id', $id)->update(['disable' => 1]);
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'disabled pending account');
|
|
expectProvision(Db::name('admin')->count() === 2, 'no duplicates');
|
|
$context['localIdentityKey'] = $context['employee']['oidcSubject'] = 'other-user';
|
|
Db::name('system_role')->where('id', 2)->update(['name' => 'Wrong Role']);
|
|
rejectProvision(fn() => $p->resolve($client, $config, 'other-user'), 'wrong role');
|
|
expectProvision(Db::name('iam_local_identity')->count() === 1 && Db::name('admin')->count() === 2, 'failed transaction fully rolled back');
|
|
if (getenv('IAM_TEST_MYSQL') === '1') {
|
|
Db::name('system_role')->where('id', 2)->update(['name' => '医助']);
|
|
$workers = [];
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$process = proc_open([PHP_BINARY, __FILE__, '--worker'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
$workers[] = [$process, $pipes];
|
|
}
|
|
$ids = [];
|
|
foreach ($workers as [$process, $pipes]) {
|
|
$output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]); fclose($pipes[2]);
|
|
expectProvision(proc_close($process) === 0, 'concurrent worker: ' . $error);
|
|
$ids[] = (int) $output;
|
|
}
|
|
expectProvision(count(array_unique($ids)) === 1 && $ids[0] > 0 && Db::name('admin')->count() === 3, 'eight workers one account');
|
|
expectProvision(Db::name('admin_role')->where('admin_id', $ids[0])->count() === 1, 'one role association');
|
|
echo "IamProvisioningMySQLConcurrency passed (8 workers, one identity/account/role, transaction retries)\n";
|
|
}
|
|
echo "IamProvisioningTest passed (database transactions, role2, grant denial, no name merge, durable retry, binding confirmation, existing-role preservation, disabled account, rollback)\n";
|
|
} finally {
|
|
@unlink($file);
|
|
}
|