This commit is contained in:
Marcel Pociot
2020-04-30 15:17:25 +02:00
parent 0dfda0825c
commit a972c8581c
24 changed files with 444 additions and 29 deletions

View File

@@ -6,6 +6,7 @@ use App\Client\Connections\ControlConnection;
use App\Logger\CliRequestLogger;
use Ratchet\Client\WebSocket;
use React\EventLoop\LoopInterface;
use React\Promise\PromiseInterface;
use function Ratchet\Client\connect;
class Client
@@ -37,8 +38,11 @@ class Client
}
}
protected function connectToServer(string $sharedUrl, $subdomain)
public function connectToServer(string $sharedUrl, $subdomain): PromiseInterface
{
$deferred = new \React\Promise\Deferred();
$promise = $deferred->promise();
$token = config('expose.auth_token');
$wsProtocol = $this->configuration->port() === 443 ? "wss" : "ws";
@@ -46,7 +50,7 @@ class Client
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$token}", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain) {
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $deferred) {
$connection = ControlConnection::create($clientConnection);
$connection->authenticate($sharedUrl, $subdomain);
@@ -66,7 +70,7 @@ class Client
exit(1);
});
$connection->on('authenticated', function ($data) {
$connection->on('authenticated', function ($data) use ($deferred) {
$httpProtocol = $this->configuration->port() === 443 ? "https" : "http";
$host = $this->configuration->host();
@@ -77,12 +81,19 @@ class Client
$this->logger->info("Connected to {$httpProtocol}://{$data->subdomain}.{$host}");
static::$subdomains[] = "$data->subdomain.{$this->configuration->host()}:{$this->configuration->port()}";
$deferred->resolve();
});
}, function (\Exception $e) {
}, function (\Exception $e) use ($deferred) {
$this->logger->error("Could not connect to the server.");
$this->logger->error($e->getMessage());
$deferred->reject();
exit(1);
});
return $promise;
}
}

View File

@@ -86,12 +86,17 @@ class Factory
});
}
public function createClient($sharedUrl, $subdomain = null, $auth = null)
public function createClient()
{
$this->bindConfiguration();
$this->bindProxyManager();
return $this;
}
public function share($sharedUrl, $subdomain = null)
{
app(Client::class)->share($sharedUrl, $subdomain);
return $this;
@@ -131,7 +136,7 @@ class Factory
echo("Started Dashboard on port {$dashboardPort}" . PHP_EOL);
echo('If the dashboard does not automatically open, visit: ' . $dashboardUrl . PHP_EOL);
echo('You can visit the dashboard at: ' . $dashboardUrl . PHP_EOL);
});
$this->app = new App('127.0.0.1', $dashboardPort, '0.0.0.0', $this->loop);

View File

@@ -15,7 +15,7 @@ class DashboardController extends Controller
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(respond_html($this->getView('client.dashboard', [
$httpConnection->send(respond_html($this->getView($httpConnection, 'client.dashboard', [
'subdomains' => Client::$subdomains,
])));
}

View File

@@ -33,7 +33,8 @@ class ShareCommand extends Command
->setHost(config('expose.host', 'localhost'))
->setPort(config('expose.port', 8080))
->setAuth($this->option('auth'))
->createClient($this->argument('host'), explode(',', $this->option('subdomain')))
->createClient()
->share($this->argument('host'), explode(',', $this->option('subdomain')))
->createHttpServer()
->run();
}

View File

@@ -2,13 +2,14 @@
namespace App\Http\Controllers\Concerns;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\stream_for;
trait LoadsViews
{
protected function getView(string $view, array $data = [])
protected function getView(ConnectionInterface $connection, string $view, array $data = [])
{
$templatePath = implode(DIRECTORY_SEPARATOR, explode('.', $view));
@@ -19,6 +20,10 @@ trait LoadsViews
])
);
$data = array_merge($data, [
'request' => $connection->laravelRequest ?? null,
]);
return stream_for($twig->render('template', $data));
}
}

View File

@@ -26,10 +26,10 @@ trait ParsesIncomingRequest
protected function checkContentLength(ConnectionInterface $connection)
{
if (strlen($connection->requestBuffer) === $connection->contentLength) {
$laravelRequest = $this->createLaravelRequest($connection);
$connection->laravelRequest = $this->createLaravelRequest($connection);
if ($this->shouldHandleRequest($laravelRequest, $connection)) {
$this->handle($laravelRequest, $connection);
if ($this->shouldHandleRequest($connection->laravelRequest, $connection)) {
$this->handle($connection->laravelRequest, $connection);
}
if (!$this->keepConnectionOpen) {

View File

@@ -12,7 +12,8 @@ use function GuzzleHttp\Psr7\parse_request;
abstract class Controller implements HttpServerInterface
{
use LoadsViews, ParsesIncomingRequest;
use LoadsViews;
use ParsesIncomingRequest;
protected $keepConnectionOpen = false;
@@ -29,6 +30,7 @@ abstract class Controller implements HttpServerInterface
public function onClose(ConnectionInterface $connection)
{
unset($connection->laravelRequest);
unset($connection->requestBuffer);
unset($connection->contentLength);
unset($connection->request);

View File

@@ -26,4 +26,15 @@ class Configuration
{
return $this->port;
}
public function __isset($key)
{
return property_exists($this, $key) || ! is_null(config('expose.admin.'.$key));
}
public function __get($key)
{
dump(config('expose.admin'));
return $this->$key ?? config('expose.admin.'.$key);
}
}

View File

@@ -11,6 +11,8 @@ use App\Server\Http\Controllers\Admin\ListSitesController;
use App\Server\Http\Controllers\Admin\ListUsersController;
use App\Server\Http\Controllers\Admin\LoginController;
use App\Server\Http\Controllers\Admin\RedirectToUsersController;
use App\Server\Http\Controllers\Admin\SaveSettingsController;
use App\Server\Http\Controllers\Admin\ShowSettingsController;
use App\Server\Http\Controllers\Admin\StoreUsersController;
use App\Server\Http\Controllers\Admin\VerifyLoginController;
use App\Server\Http\Controllers\ControlMessageController;
@@ -114,6 +116,8 @@ class Factory
$this->router->get('/', RedirectToUsersController::class, $adminCondition);
$this->router->get('/users', ListUsersController::class, $adminCondition);
$this->router->get('/settings', ShowSettingsController::class, $adminCondition);
$this->router->post('/settings', SaveSettingsController::class, $adminCondition);
$this->router->post('/users', StoreUsersController::class, $adminCondition);
$this->router->delete('/users/delete/{id}', DeleteUsersController::class, $adminCondition);
$this->router->get('/sites', ListSitesController::class, $adminCondition);
@@ -183,7 +187,7 @@ class Factory
{
app()->singleton(DatabaseInterface::class, function() {
$factory = new \Clue\React\SQLite\Factory($this->loop);
return $factory->openLazy(base_path('database/expose.db'));
return $factory->openLazy(config('expose.admin.database', ':memory:'));
});
return $this;
@@ -210,7 +214,7 @@ class Factory
public function validateAuthTokens(bool $validate)
{
config()->set('expose.validate_auth_tokens', $validate);
config()->set('expose.admin.validate_auth_tokens', $validate);
return $this;
}

View File

@@ -31,7 +31,7 @@ class ListSitesController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection)
{
try {
$sites = $this->getView('server.sites.index', [
$sites = $this->getView($httpConnection, 'server.sites.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration,
'sites' => $this->connectionManager->getConnections()

View File

@@ -29,7 +29,7 @@ class ListUsersController extends AdminController
{
$this->database->query('SELECT * FROM users ORDER by created_at DESC')->then(function (Result $result) use ($httpConnection) {
$httpConnection->send(
respond_html($this->getView('server.users.index', ['users' => $result->rows]))
respond_html($this->getView($httpConnection, 'server.users.index', ['users' => $result->rows]))
);
$httpConnection->close();

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Http\Controllers\Controller;
use App\Server\Configuration;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\str;
use function GuzzleHttp\Psr7\stream_for;
class SaveSettingsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
$this->configuration = $configuration;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
config()->set('expose.admin.validate_auth_tokens', $request->has('validate_auth_tokens'));
$httpConnection->send(str(new Response(301, [
'Location' => '/settings'
])));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Http\Controllers\Controller;
use App\Server\Configuration;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\str;
use function GuzzleHttp\Psr7\stream_for;
class ShowSettingsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
$this->configuration = $configuration;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(
respond_html($this->getView($httpConnection, 'server.settings.index', [
'configuration' => $this->configuration,
]))
);
}
}

View File

@@ -74,7 +74,7 @@ class ControlMessageController implements MessageComponentInterface
protected function authenticate(ConnectionInterface $connection, $data)
{
if (config('expose.validate_auth_tokens') === true) {
if (config('expose.admin.validate_auth_tokens') === true) {
$this->verifyAuthToken($connection);
}

View File

@@ -44,7 +44,7 @@ class TunnelMessageController extends Controller
if (is_null($controlConnection)) {
$httpConnection->send(
respond_html($this->getView('server.errors.404', ['subdomain' => $subdomain]))
respond_html($this->getView($httpConnection, 'server.errors.404', ['subdomain' => $subdomain]), 404)
);
$httpConnection->close();
return;

View File

@@ -42,6 +42,7 @@
"nyholm/psr7": "^1.2",
"phpunit/phpunit": "^8.5",
"ratchet/pawl": "^0.3.4",
"react/http": "^0.8.6",
"react/socket": "^1.4",
"riverline/multipart-parser": "^2.0",
"symfony/expression-language": "^5.0",

50
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "17ed4fb1b80fc6efe2594c3bfbbf3c7f",
"content-hash": "801c0ab8f694ff370f2eefea409a7fcc",
"packages": [],
"packages-dev": [
{
@@ -4470,6 +4470,54 @@
],
"time": "2020-01-01T18:39:52+00:00"
},
{
"name": "react/http",
"version": "v0.8.6",
"source": {
"type": "git",
"url": "https://github.com/reactphp/http.git",
"reference": "248202e57195d06a4375f6d2f5c5b9ff9da3ea9e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/http/zipball/248202e57195d06a4375f6d2f5c5b9ff9da3ea9e",
"reference": "248202e57195d06a4375f6d2f5c5b9ff9da3ea9e",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.0",
"react/promise": "^2.3 || ^1.2.1",
"react/promise-stream": "^1.1",
"react/socket": "^1.0 || ^0.8.3",
"react/stream": "^1.0 || ^0.7.1",
"ringcentral/psr7": "^1.2"
},
"require-dev": {
"clue/block-react": "^1.1",
"phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Http\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Event-driven, streaming plaintext HTTP and secure HTTPS server for ReactPHP",
"keywords": [
"event-driven",
"http",
"https",
"reactphp",
"server",
"streaming"
],
"time": "2020-01-12T13:15:06+00:00"
},
{
"name": "react/http-client",
"version": "v0.5.10",

View File

@@ -2,11 +2,15 @@
return [
'host' => 'expose.dev',
'port' => 8080,
'port' => 443,
'auth_token' => '',
'admin' => [
'database' => base_path('database/expose.db'),
'validate_auth_tokens' => false,
/*
|--------------------------------------------------------------------------
| Subdomain

View File

@@ -14,15 +14,21 @@
</div>
<div class="hidden sm:-my-px sm:ml-6 sm:flex">
<a href="/users"
class="inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out">
class="
{% if request.is('users*') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %}
inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Users
</a>
<a href="/sites"
class="ml-8 inline-flex items-center px-1 pt-1 border-b-2 border-indigo-500 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out">
class="
{% if request.is('sites') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %}
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Shared sites
</a>
<a href="#"
class="ml-8 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out">
<a href="/settings"
class="
{% if request.is('settings') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %}
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Settings
</a>
</div>

View File

@@ -0,0 +1,61 @@
{% extends "app" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="flex flex-col py-8">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<form action="" method="POST">
<div>
<div>
<div class="">
<div class="">
<div role="group" aria-labelledby="label-email">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div class="text-base leading-6 font-medium text-gray-900 sm:text-sm sm:leading-5 sm:text-gray-700" id="label-email">
Authentication
</div>
</div>
<div class="mt-4 sm:mt-0 sm:col-span-2">
<div class="max-w-lg">
<div class="relative flex items-start">
<div class="absolute flex items-center h-5">
<input id="authentication"
type="checkbox"
name="validate_auth_tokens"
value="1"
{% if configuration.validate_auth_tokens %} checked="checked" {% endif %}
class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
</div>
<div class="pl-7 text-sm leading-5">
<label for="authentication" class="font-medium text-gray-700">Require authentication tokens</label>
<p class="text-gray-500">Only allow connection from clients with valid authentication tokens</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end">
<span class="inline-flex rounded-md shadow-sm">
<button type="button"
class="py-2 px-4 border border-gray-300 rounded-md text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition duration-150 ease-in-out">
Cancel
</button>
</span>
<span class="ml-3 inline-flex rounded-md shadow-sm">
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent text-sm leading-5 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out">
Save
</button>
</span>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,11 +2,15 @@
namespace Tests\Feature\Server;
use App\Contracts\ConnectionManager;
use App\Http\Server;
use App\Server\Factory;
use Clue\React\Buzz\Browser;
use Clue\React\Buzz\Message\ResponseException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface;
use Ratchet\Server\IoConnection;
use React\Socket\Connector;
use Tests\Feature\TestCase;
@@ -49,10 +53,6 @@ class AdminTest extends TestCase
/** @test */
public function it_accepts_valid_credentials()
{
$this->app['config']['expose.admin.users'] = [
'username' => 'secret',
];
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080', [
'Host' => 'expose.localhost',
@@ -61,8 +61,67 @@ class AdminTest extends TestCase
$this->assertSame(200, $response->getStatusCode());
}
/** @test */
public function it_allows_saving_settings()
{
$this->app['config']['expose.admin.validate_auth_tokens'] = false;
/** @var ResponseInterface $response */
$this->await($this->browser->post('http://127.0.0.1:8080/settings', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode("username:secret"),
'Content-Type' => 'application/json'
], json_encode([
'validate_auth_tokens' => true,
])));
$this->assertTrue(config('expose.admin.validate_auth_tokens'));
}
/** @test */
public function it_can_create_users()
{
$this->await($this->browser->post('http://127.0.0.1:8080/users', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode("username:secret"),
'Content-Type' => 'application/json'
], json_encode([
'name' => 'Marcel',
])));
$this->assertDatabaseHasResults('SELECT * FROM users WHERE name = "Marcel"');
}
/** @test */
public function it_can_list_all_currently_connected_sites()
{
/** @var ConnectionManager $connectionManager */
$connectionManager = app(ConnectionManager::class);
$connection = \Mockery::mock(IoConnection::class);
$connectionManager->storeConnection('some-host.text', 'fixed-subdomain', $connection);
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/sites', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode("username:secret"),
'Content-Type' => 'application/json'
]));
$body = $response->getBody()->getContents();
$this->assertTrue(Str::contains($body, 'some-host.text'));
$this->assertTrue(Str::contains($body, 'fixed-subdomain'));
}
protected function startServer()
{
$this->app['config']['expose.admin.database'] = ':memory:';
$this->app['config']['expose.admin.users'] = [
'username' => 'secret',
];
$this->serverFactory = new Factory();
$this->serverFactory->setLoop($this->loop)

View File

@@ -0,0 +1,103 @@
<?php
namespace Tests\Feature\Server;
use App\Client\Client;
use App\Contracts\ConnectionManager;
use App\Server\Factory;
use Clue\React\Buzz\Browser;
use Clue\React\Buzz\Message\ResponseException;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface;
use Ratchet\Client\WebSocket;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
use React\Http\Server;
use Tests\Feature\TestCase;
use function Ratchet\Client\connect;
class TunnelTest extends TestCase
{
/** @var Browser */
protected $browser;
/** @var Factory */
protected $serverFactory;
public function setUp(): void
{
parent::setUp();
$this->browser = new Browser($this->loop);
$this->startServer();
}
/** @test */
public function it_returns_404_for_non_existing_clients()
{
$this->expectException(ResponseException::class);
$this->expectExceptionMessage(404);
$response = $this->await($this->browser->get('http://127.0.0.1:8080/', [
'Host' => 'tunnel.localhost'
]));
}
/** @test */
public function it_sends_incoming_requests_to_the_connected_client()
{
$this->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server
*/
$client = $this->createClient();
$this->await($client->connectToServer('127.0.0.1:8085', 'tunnel'));
/**
* Once the client is connected, we perform a GET request on the
* created tunnel.
*/
$response = $this->await($this->browser->get('http://127.0.0.1:8080/', [
'Host' => 'tunnel.localhost'
]));
$this->assertSame('Hello World!', $response->getBody()->getContents());
}
protected function startServer()
{
$this->app['config']['expose.admin.database'] = ':memory:';
$this->serverFactory = new Factory();
$this->serverFactory->setLoop($this->loop)
->setHost('127.0.0.1')
->setHostname('localhost')
->createServer();
}
protected function createClient()
{
(new \App\Client\Factory())
->setLoop($this->loop)
->setHost('127.0.0.1')
->setPort(8080)
->createClient();
return app(Client::class);
}
protected function createTestHttpServer()
{
$server = new Server(function (ServerRequestInterface $request) {
return new Response(200, ['Content-Type' => 'text/plain'], "Hello World!");
});
$socket = new \React\Socket\Server(8085, $this->loop);
$server->listen($socket);
}
}

View File

@@ -2,6 +2,10 @@
namespace Tests\Feature;
use Clue\React\SQLite\DatabaseInterface;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface;
use React\EventLoop\Factory;
use React\EventLoop\LoopInterface;
use React\EventLoop\StreamSelectLoop;
@@ -33,4 +37,13 @@ abstract class TestCase extends \Tests\TestCase
{
return await($promise, $loop ?? $this->loop, $timeout ?? static::AWAIT_TIMEOUT);
}
protected function assertDatabaseHasResults($query)
{
$database = app(DatabaseInterface::class);
$result = $this->await($database->query($query));
$this->assertGreaterThanOrEqual(1, count($result->rows));
}
}

View File

@@ -2,6 +2,7 @@
namespace Tests;
use Clue\React\SQLite\DatabaseInterface;
use LaravelZero\Framework\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase