`Merge branch 'master' into chufang-9-9
This commit is contained in:
Your Name
2026-09-10 15:20:45 +08:00
820 changed files with 6668 additions and 13 deletions
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { createRequire } from 'node:module'
const require = createRequire(new URL('../../admin/package.json', import.meta.url))
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const filename = new URL('../../admin/src/views/account/login.vue', import.meta.url).pathname
const source = fs.readFileSync(filename, 'utf8')
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const template = descriptor.template.content
assert.equal((template.match(/@click="handleIamLogin"/g) || []).length, 1, 'exactly one IAM entry')
const section = template.match(/<section v-if="iamEnabled" class="iam-login-alternative"[\s\S]*?<\/section>/)?.[0]
assert.ok(section, 'IAM alternative is enabled only by iamEnabled')
assert.match(section, /@click="handleIamLogin"/)
assert.ok(template.indexOf(section) > template.indexOf('@click="lockLogin"'), 'IAM follows local login')
assert.ok(template.indexOf(section) > template.indexOf('请使用企业微信扫描二维码登录'), 'IAM follows WeCom region')
assert.ok(template.indexOf(section) > template.indexOf('</el-radio-group>'), 'no IAM above mode switch')
assert.match(template, /<el-radio-button value="account">账号登录<\/el-radio-button>/)
assert.match(template, /<el-radio-button value="wxwork">企业微信<\/el-radio-button>/)
const script = compileScript(descriptor, { id: 'iam-login-placement' })
const compiled = compileTemplate({ source: template, filename, id: 'iam-login-placement', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(compiled.errors, [])
console.log('PASS IAM placement: single optional bottom entry, original handler, account/WeCom switch, local/WeCom ordering, SFC script/template compilation')
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace think\facade {
// In-memory config only: no application bootstrap, database, cache or network.
final class Config
{
public static array $values = [];
public static function get(string $key, $default = null) { return self::$values[$key] ?? $default; }
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\iam\IamLoginService;
use app\adminapi\service\iam\IamLoginTransactionStore;
use think\facade\Config;
function txExpect(bool $ok, string $message): void {
if (!$ok) { throw new \RuntimeException('FAIL: ' . $message); }
}
function txReject(callable $call, string $message): void {
try { $call(); } catch (\Throwable $error) { return; }
throw new \RuntimeException('FAIL: accepted ' . $message);
}
$directory = sys_get_temp_dir() . '/zyt-iam-test-' . bin2hex(random_bytes(12));
try {
$store = new IamLoginTransactionStore($directory);
$value = bin2hex(random_bytes(32));
$browser = bin2hex(random_bytes(32));
$store->put('state', $value, $browser, ['nonce' => 'nonce'], 600);
txReject(fn() => $store->consume('state', $value, 'another-browser'), 'wrong browser');
txReject(fn() => $store->consume('ticket', $value, $browser), 'state as ticket');
txExpect($store->consume('state', $value, $browser) === ['nonce' => 'nonce'], 'correct browser can consume after rejected foreign browser');
txReject(fn() => $store->consume('state', $value, $browser), 'state replay');
$store->put('ticket', $value, $browser, ['subject' => 'employee'], 60);
txReject(fn() => $store->consume('state', $value, $browser), 'ticket as state');
txExpect($store->consume('ticket', $value, $browser) === ['subject' => 'employee'], 'same value separate ticket namespace');
txReject(fn() => $store->consume('ticket', $value, $browser), 'ticket replay');
foreach (['state', 'ticket'] as $kind) {
$expired = bin2hex(random_bytes(32));
$store->put($kind, $expired, $browser, ['expired' => true], -1);
txReject(fn() => $store->consume($kind, $expired, $browser), 'expired ' . $kind);
}
foreach (['../escape', '', str_repeat('z', 64), str_repeat('a', 63)] as $invalid) {
txReject(fn() => $store->put('state', $invalid, $browser, [], 60), 'invalid transaction identifier');
}
txReject(fn() => $store->consume('invalid-kind', $value, $browser), 'invalid namespace');
foreach (glob($directory . '/*.json') ?: [] as $file) {
txExpect((fileperms($file) & 0777) === 0600, 'private file permissions');
txExpect(!str_contains((string) file_get_contents($file), $browser), 'browser cookie is hashed at rest');
}
for ($i = 1; $i <= 20; ++$i) {
txExpect($store->allowStart('192.0.2.1'), 'allowed request ' . $i);
}
txExpect(!$store->allowStart('192.0.2.1'), 'request 21 rejected');
txExpect(!$store->allowStart('192.0.2.1'), 'later request rejected');
txExpect($store->allowStart('192.0.2.2'), 'rate limit IP isolation');
$limit = $directory . '/limit-' . hash('sha256', '192.0.2.1') . '.json';
file_put_contents($limit, json_encode(['count' => 20, 'expires' => time() - 1]));
txExpect($store->allowStart('192.0.2.1'), 'expired rate limit resets');
$context = ['applicationId' => 'zyt', 'localIdentityKey' => 'employee',
'employee' => ['oidcSubject' => 'employee', 'status' => 'active'],
'externalAccountId' => '42', 'shouldCreateLocalAccount' => false];
txExpect(IamLoginService::boundAdminId($context, 'employee', 'zyt') === 42, 'explicit existing binding');
foreach (['applicationId' => 'other', 'localIdentityKey' => 'other', 'externalAccountId' => '',
'shouldCreateLocalAccount' => true] as $field => $bad) {
$changed = $context;
$changed[$field] = $bad;
txReject(fn() => IamLoginService::boundAdminId($changed, 'employee', 'zyt'), $field);
}
foreach (['oidcSubject' => 'other', 'status' => 'disabled'] as $field => $bad) {
$changed = $context;
$changed['employee'][$field] = $bad;
txReject(fn() => IamLoginService::boundAdminId($changed, 'employee', 'zyt'), 'employee ' . $field);
}
foreach (['0', '-1', '01', '42.1', '42e1', 'admin', '42 ', '99999999999', null, true, false, 42.0] as $bad) {
$changed = $context;
$changed['externalAccountId'] = $bad;
txReject(fn() => IamLoginService::boundAdminId($changed, 'employee', 'zyt'), 'invalid externalAccountId');
}
foreach (array_keys($context) as $field) {
$changed = $context;
unset($changed[$field]);
txReject(fn() => IamLoginService::boundAdminId($changed, 'employee', 'zyt'), 'missing ' . $field);
}
$changed = $context;
$changed['shouldCreateLocalAccount'] = 'false';
txReject(fn() => IamLoginService::boundAdminId($changed, 'employee', 'zyt'), 'non-boolean creation flag');
foreach ([[], ['enabled' => false], ['enabled' => 'false'], ['enabled' => true]] as $config) {
Config::$values = ['iam' => $config];
txExpect(IamLoginService::settings() === ['enabled' => false, 'loginUrl' => ''], 'disabled/incomplete config stays closed');
txReject(fn() => new IamLoginService(), 'disabled constructor without bootstrap');
}
$config = ['enabled' => true, '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' => 'secret',
'public_url' => 'https://zyt.example.test'];
Config::$values = ['iam' => $config];
txExpect(IamLoginService::settings() === ['enabled' => true, 'loginUrl' => 'https://zyt.example.test/adminapi/iam/start'], 'complete config enables without HTTP');
$config['redirect_uri'] = 'https://zyt.example.test/wrong';
Config::$values = ['iam' => $config];
txExpect(IamLoginService::settings()['enabled'] === false, 'callback URI mismatch');
echo "IamLoginTransactionTest passed (browser binding, replay, namespaces, expiry, 20-request limit, explicit binding, disabled config; no DB/network)\n";
} finally {
foreach (glob($directory . '/*') ?: [] as $file) { unlink($file); }
if (is_dir($directory)) { rmdir($directory); }
}
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import vm from 'node:vm'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const ts = require('typescript')
const root = new URL('../../', import.meta.url)
const vue = fs.readFileSync(new URL('admin/src/views/account/login.vue', root), 'utf8')
const api = fs.readFileSync(new URL('admin/src/api/user.ts', root), 'utf8')
const store = fs.readFileSync(new URL('admin/src/stores/modules/user.ts', root), 'utf8')
const script = vue.match(/<script lang="ts" setup>([\s\S]*?)<\/script>/)[1]
const js = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText
// Imports are injected with controlled test doubles; all actual component handlers execute.
const runnable = js.replace(/^.*require\(.+\);?$/gm, '').replace(/^Object.defineProperty\(exports.*$/gm, '')
async function scenario(query, result, fail = false, enabled = true, wecomEnabled = false) {
const calls = [], paths = [], errors = []
let mounted, cleaned = ''
const ref = value => ({ value })
const userStore = {
iamLogin: async ticket => { calls.push(['iam', ticket, cleaned]); if (fail) throw new Error('expired'); return result },
workWechatLogin: async code => { calls.push(['wechat', code]); return result }
}
const c = {
exports: {}, console, URL, URLSearchParams,
vue_1: { ref, shallowRef: ref, computed: fn => ref(fn()), watch() {}, nextTick: async fn => fn?.(), onMounted: fn => { mounted = fn } },
user_2: { getIamConfig: async () => ({ enabled, loginUrl: 'https://iam.example/login' }), getWorkWechatConfig: async () => ({ enabled: wecomEnabled, corp_id: 'corp', agent_id: 'agent' }) },
user_1: { default: () => userStore },
app_1: { default: () => ({ config: {} }) },
cache_1: { default: { get() {}, set() {} } },
cacheEnums_1: { ACCOUNT_KEY: 'account' },
pageEnum_1: { PageEnum: { INDEX: '/' } },
useLockFn_1: { useLockFn: fn => ({ isLock: ref(false), lockFn: fn }) },
element_plus_1: { ElMessage: { error: text => errors.push(text) } },
reactive: value => value,
useRoute: () => ({ query: { redirect: '/dashboard' } }),
useRouter: () => ({ push: async path => paths.push(path) }),
navigator: { userAgent: 'wxwork' },
window: { location: { href: `https://zyt.example/admin/login${query}`, origin: 'https://zyt.example', pathname: '/admin/login', search: query, assign: url => calls.push(['assign', url]) }, history: { state: { back: '/' }, replaceState: (_, __, url) => { cleaned = url } } }
}
vm.createContext(c)
vm.runInContext(runnable, c)
await mounted()
await Promise.resolve()
return { calls, paths, errors, cleaned, context: c }
}
for (const [result, destination] of [[{ is_paw: 0, need_bind_work_wechat: true }, '/change-password'], [{ is_paw: 1, need_bind_work_wechat: true }, '/bind-work-wechat'], [{ is_paw: 1 }, '/dashboard']]) {
const s = await scenario('?iam_ticket=one-use&code=wecom&state=admin_login&redirect=%2Fdashboard', result)
assert.equal(s.calls.length, 1)
assert.equal(s.calls[0][0], 'iam')
assert.equal(s.calls[0][1], 'one-use')
assert.equal(s.calls[0][2], '/admin/login?redirect=%2Fdashboard')
assert.deepEqual(s.paths, [destination])
await vm.runInContext("handleIamCallback('one-use', null)", s.context)
assert.equal(s.calls.length, 1)
}
for (const query of ['?iam_error=Denied&code=wecom&state=admin_login', '?iam_ticket=']) {
const s = await scenario(query, {})
assert.equal(s.calls.length, 0)
assert.equal(s.errors.length, 1)
assert.equal(s.cleaned, '/admin/login')
}
const failed = await scenario('?iam_ticket=expired', {}, true)
assert.equal(failed.errors[0], 'expired')
assert.equal(vm.runInContext('iamLoading.value', failed.context), false)
const iamErrorWithWecom = await scenario('?iam_error=Denied', {}, false, true, true)
assert.equal(vm.runInContext('wxWorkEnabled.value', iamErrorWithWecom.context), true)
assert.equal(vm.runInContext('wxWorkAutoLogin.value', iamErrorWithWecom.context), false)
assert.equal(iamErrorWithWecom.calls.length, 0)
const wecom = await scenario('?code=original&state=admin_login', { is_paw: 1 })
assert.equal(wecom.calls[0][0], 'wechat')
assert.equal(wecom.calls[0][1], 'original')
const disabled = await scenario('', {}, false, false)
vm.runInContext('handleIamLogin()', disabled.context)
assert.equal(disabled.calls.length, 0)
const enabled = await scenario('', {})
assert.equal(enabled.calls.length, 0, 'IAM never auto redirects')
vm.runInContext('handleIamLogin()', enabled.context)
assert.equal(enabled.calls[0][0], 'assign')
assert.match(api, /url: '\/iam\/exchange'[\s\S]*withCredentials: true/)
assert.match(api, /withToken: false, isOpenRetry: false/)
assert.match(store, /async iamLogin\(ticket: string\)[\s\S]*cache\.set\(TOKEN_KEY, data\.token\)/)
assert.match(vue, /v-if="iamEnabled"/)
assert.match(vue, /userStore\.login\(formData\)/)
assert.match(vue, /userStore\.workWechatLogin\(code\)/)
console.log('PASS IAM UI: optional entry, callback priority, URL cleanup, one exchange, error recovery, password/bind/local redirects, existing login contracts')
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\iam\IamOidcClient;
use Firebase\JWT\JWT;
function iamExpect(bool $ok, string $label): void
{
if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
}
function iamReject(callable $call, string $label): void
{
try { $call(); } catch (Throwable $error) { return; }
throw new RuntimeException('FAIL: accepted ' . $label);
}
function iamB64(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); }
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$details = openssl_pkey_get_details($key);
$jwks = ['keys' => [['kty' => 'RSA', 'kid' => 'test', 'alg' => 'RS256', 'use' => 'sig',
'n' => iamB64($details['rsa']['n']), 'e' => iamB64($details['rsa']['e'])]]];
$config = ['issuer' => 'https://iam.example.test', 'client_id' => 'zyt', 'client_secret' => 'secret',
'redirect_uri' => 'https://zyt.example.test/admin/iam/callback', 'api_url' => 'https://iam.example.test',
'application_id' => 'zyt app', 'application_token' => 'application-secret'];
$nonce = str_repeat('n', 32);
$verifier = str_repeat('v', 64);
$metadata = ['issuer' => $config['issuer'], 'authorization_endpoint' => $config['issuer'] . '/oauth/authorize',
'token_endpoint' => $config['issuer'] . '/oauth/token', 'jwks_uri' => $config['issuer'] . '/oauth/jwks'];
$claims = ['iss' => $config['issuer'], 'aud' => 'zyt', 'sub' => 'employee-7', 'nonce' => $nonce,
'iat' => time() - 1, 'exp' => time() + 300, 'nbf' => time() - 1];
$token = JWT::encode($claims, $key, 'RS256', 'test');
$calls = [];
$transport = function (string $method, string $url, array $headers, string $body) use (&$metadata, &$token, &$jwks, &$calls, $config, $verifier): array {
$calls[] = [$method, $url, $headers, $body];
if (str_ends_with($url, '/.well-known/openid-configuration')) { $data = $metadata; }
elseif (str_ends_with($url, '/oauth/token')) {
parse_str($body, $params);
iamExpect($method === 'POST' && $params['code_verifier'] === $verifier && $params['redirect_uri'] === $config['redirect_uri'], 'token form');
iamExpect(in_array('Authorization: Basic ' . base64_encode('zyt:secret'), $headers, true), 'client authentication');
$data = ['id_token' => $token];
} elseif (str_ends_with($url, '/oauth/jwks')) { $data = $jwks; }
elseif (str_ends_with($url, '/provisioning-context')) {
iamExpect(in_array('Authorization: Bearer application-secret', $headers, true), 'application bearer');
iamExpect(str_contains($url, '/zyt%20app/employees/employee-7/'), 'encoded provisioning path');
$data = ['externalAccountId' => '9'];
} else { throw new RuntimeException('Unexpected request'); }
return ['status' => 200, 'body' => json_encode($data, JSON_THROW_ON_ERROR)];
};
$client = new IamOidcClient($config, $transport);
parse_str(parse_url($client->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), PHP_URL_QUERY), $query);
iamExpect($query['code_challenge_method'] === 'S256' && $query['code_challenge'] === iamB64(hash('sha256', $verifier, true)), 'PKCE S256');
iamExpect($client->exchange('code', $verifier, $nonce)['sub'] === 'employee-7', 'valid token');
iamExpect($client->provisioning('employee-7')['externalAccountId'] === '9', 'provisioning');
foreach (['iss' => 'https://other.example.test', 'aud' => 'other', 'nonce' => 'wrong', 'exp' => time() - 1,
'nbf' => time() + 100, 'iat' => time() + 100, 'azp' => 'other', 'sub' => ''] as $field => $value) {
$changed = $claims;
$changed[$field] = $value;
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), $field);
}
$changed = $claims;
$changed['aud'] = ['zyt', 'other'];
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'multiple audiences without azp');
$changed['azp'] = 'zyt';
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamExpect($client->exchange('code', $verifier, $nonce)['azp'] === 'zyt', 'multiple audience azp');
$token = JWT::encode($claims, $other, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'wrong signature');
$token = JWT::encode($claims, str_repeat('s', 64), 'HS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'algorithm confusion');
$token = JWT::encode($claims, $key, 'RS256', 'unknown');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'unknown kid');
iamReject(fn() => $client->authorizationUrl(str_repeat('s', 32), $nonce, 'short'), 'short verifier');
$metadata['issuer'] = 'https://other.example.test';
iamReject(fn() => (new IamOidcClient($config, $transport))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'discovery issuer');
$metadata['issuer'] = $config['issuer'];
$metadata['jwks_uri'] = 'https://other.example.test/jwks';
iamReject(fn() => (new IamOidcClient($config, $transport))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'cross-origin JWKS');
foreach (['http://iam.example.test', 'https://user@iam.example.test', 'https://iam.example.test/#fragment'] as $invalid) {
$bad = $config;
$bad['issuer'] = $invalid;
iamReject(fn() => new IamOidcClient($bad, $transport), 'invalid HTTPS configuration');
}
$bad = $config;
$bad['api_url'] = 'https://other.example.test';
iamReject(fn() => new IamOidcClient($bad, $transport), 'cross-origin provisioning');
iamReject(fn() => (new IamOidcClient($config, fn() => ['status' => 302, 'body' => '{}']))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'HTTP redirect');
iamReject(fn() => (new IamOidcClient($config, fn() => ['status' => 200, 'body' => 'invalid']))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'invalid JSON');
echo "IamOidcClientTest passed (offline RSA, claims, discovery, PKCE, provisioning, HTTPS)\n";
+133
View File
@@ -0,0 +1,133 @@
<?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);
}