load(); \support\App::loadAllConfig(['route']); use app\miniapi\auth\TestPhoneIdentityProvider; use app\miniapi\auth\SessionVerifier; use app\miniapi\controller\AuthController; use app\miniapi\exception\ApiException; use app\miniapi\model\MiniUser; use app\miniapi\model\MiniSession; use app\miniapi\service\TestPhoneLogin; use app\miniapi\service\AuthService; final class TestPhoneRequest extends \support\Request { public function __construct(private readonly string $source, array $input, string $forwarded = '') { $body = json_encode($input, JSON_THROW_ON_ERROR); parent::__construct( "POST /api/mini/v1/auth/test-phone HTTP/1.1\r\nHost: localhost\r\n" . "Content-Type: application/json\r\nX-Forwarded-For: " . $forwarded . "\r\n" . 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body ); } public function getRemoteIp(): string { return $this->source; } } $checks = 0; function check(bool $ok, string $label): void { global $checks; $checks++; if (!$ok) { throw new RuntimeException($label); } } function reject(callable $action, int $code): void { try { $action(); } catch (ApiException $error) { check($error->getCode() === $code, 'Expected rejection ' . $code); return; } throw new RuntimeException('Missing rejection ' . $code); } function settings(array $changes): void { $property = new ReflectionProperty(\Webman\Config::class, 'config'); $all = $property->getValue(); $all['mini_api'] = array_replace($all['mini_api'], $changes); $property->setValue(null, $all); (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []); } $original = config('mini_api'); $rateFile = tempnam(sys_get_temp_dir(), 'mini-test-phone-'); $db = (new MiniUser())->getConnection(); $db->beginTransaction(); try { $service = new TestPhoneLogin(); $input = ['phone' => TestPhoneIdentityProvider::PHONES['enterprise_admin'], 'code' => '66666']; settings(['environment' => 'production', 'test_phone_login' => true]); check(TestPhoneIdentityProvider::enabled(), 'Production permits test accounts when explicitly enabled'); settings(['test_phone_login' => false]); reject(fn () => $service->login($input), 50301); settings([ 'test_phone_login' => true, 'login_throttle_file' => $rateFile, 'trusted_proxies' => ['127.0.0.1', '::1'], ]); reject(fn () => $service->login($input + ['user_id' => 1]), 42201); reject(fn () => $service->login(['phone' => '13012345681', 'code' => '66666']), 40102); reject(fn () => $service->login(['phone' => ['13012345678'], 'code' => '66666']), 42201); $users = []; foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) { $users[$role] = MiniUser::query()->firstOrCreate( ['appid' => TestPhoneIdentityProvider::APP_ID, 'openid' => $phone], ['nickname' => 'test', 'status' => 1, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s')] ); } $count = MiniUser::query()->count(); $sessions = []; foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) { $sessions[$role] = $service->login(['phone' => $phone]); check($sessions[$role]['user_id'] === (string) $users[$role]->id, 'Correct account for ' . $role); check(count($sessions[$role]['identities']) <= 1, 'At most one role for ' . $role); } check(count(array_unique(array_column($sessions, 'user_id'))) === 3, 'Distinct test accounts'); check(MiniUser::query()->count() === $count, 'No duplicate users'); // Exercise the real HTTP controller, not only the service. The original regression // happened before credential validation, at the controller's source-IP gate. $controller = new AuthController(); foreach ([ ['192.168.1.20', ''], ['203.0.113.20', ''], ['2001:db8::20', ''], ['127.0.0.1', '198.51.100.25'], ] as [$source, $forwarded]) { foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) { $request = new TestPhoneRequest($source, ['phone' => $phone], $forwarded); $response = $controller->testPhone($request); $body = json_decode($response->rawBody(), true, 512, JSON_THROW_ON_ERROR); check($response->getStatusCode() === 200 && $body['code'] === 0, 'HTTP test login succeeds from ' . $source); check($body['data']['user_id'] === (string) $users[$role]->id, 'HTTP returns the correct account for ' . $role); check( (new SessionVerifier())->verify($body['data']['access_token'])->userId === (string) $users[$role]->id, 'HTTP session authenticates for ' . $role ); } } $response = $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => $input['phone'], 'code' => '66665'])); check($response->getStatusCode() === 200, 'Legacy clients can submit an ignored code'); reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => '13800138000', 'code' => '66666'])), 40102); settings(['test_phone_login' => false]); reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', $input)), 50301); settings(['test_phone_login' => true]); $one = $sessions['enterprise_admin']; $two = $service->login($input); check($one['user_id'] === $two['user_id'], 'Stable identity'); check($one['access_token'] !== $two['access_token'], 'Fresh random sessions'); check((new SessionVerifier())->verify($one['access_token'])->userId === (string) $users['enterprise_admin']->id, 'Session authenticated'); check(MiniSession::query()->where('token_hash', hash('sha256', $one['access_token']))->exists(), 'Token hash stored'); settings(['test_phone_login' => false]); reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101); settings(['test_phone_login' => true, 'environment' => 'production']); check( (new SessionVerifier())->verify($one['access_token'])->userId === (string) $users['enterprise_admin']->id, 'Production sessions remain valid when test login is enabled' ); settings(['environment' => 'local']); MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 0]); reject(fn () => $service->login($input), 40302); reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101); MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 1]); (new AuthService())->logout($one['access_token']); reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101); check((new SessionVerifier())->verify($two['access_token'])->userId === (string) $users['enterprise_admin']->id, 'Other session still works'); } finally { $db->rollBack(); settings($original); unlink($rateFile); } echo "PASS: $checks test phone checks, database changes rolled back.\n";