create-prisma-php-app 5.0.0-alpha.8 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/dist/.github/copilot-instructions.md +191 -0
- package/dist/AGENTS.md +588 -193
- package/dist/bootstrap.php +426 -213
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/prisma-php.js +2 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/main.js +13 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/auto-swagger-docs.ts +10 -10
- package/dist/settings/bs-config.ts +47 -27
- package/dist/settings/build.ts +2 -32
- package/dist/settings/component-map.ts +476 -0
- package/dist/settings/files-list.ts +2 -2
- package/dist/settings/project-name.ts +3 -6
- package/dist/settings/restart-mcp.ts +2 -2
- package/dist/settings/restart-websocket.ts +2 -2
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/settings/swagger-config.ts +3 -3
- package/dist/settings/utils.ts +3 -3
- package/dist/src/Lib/Auth/Auth.php +62 -34
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
- package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
- package/dist/src/Lib/Websocket/Socket.php +170 -0
- package/dist/src/Lib/Websocket/SocketPool.php +50 -0
- package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
- package/dist/src/Lib/Websocket/sockets.php +50 -0
- package/dist/src/Lib/Websocket/websocket-server.php +10 -3
- package/dist/src/app/globals.css +3 -1
- package/dist/src/app/layout.php +1 -1
- package/dist/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/DeferComponentRootsTest.php +147 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +119 -0
- package/dist/tests/RpcWireContractTest.php +101 -0
- package/dist/tests/SocketPoolTest.php +69 -0
- package/dist/tests/SocketRegistryTest.php +77 -0
- package/dist/tests/SocketTest.php +124 -0
- package/dist/tests/SocketsRegistrationTest.php +40 -0
- package/dist/tests/Support/FakeConnection.php +62 -0
- package/dist/tests/Support/Features.php +38 -0
- package/dist/tests/Support/RequiresFeature.php +26 -0
- package/dist/tests/bootstrap.php +44 -0
- package/dist/ts/main.ts +21 -3
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- package/dist/README.md +0 -213
- package/dist/public/js/pp-reactive-v2.js +0 -1
- package/dist/settings/bs-config.json +0 -6
- package/dist/settings/class-imports.ts +0 -165
- package/dist/settings/class-log.ts +0 -244
- package/dist/settings/component-import-checker.ts +0 -90
- package/dist/settings/files-list.json +0 -1
- package/dist/settings/prisma-schema-config.json +0 -16
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Lib\Websocket;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A broadcast pool: one `Socket` per open connection.
|
|
9
|
+
*
|
|
10
|
+
* How a handler shares its connection with the room it belongs to: keep one
|
|
11
|
+
* pool in shared state, `add` each socket when its conversation starts, and
|
|
12
|
+
* `broadcast` fans one value out to everyone. Connections whose browser is
|
|
13
|
+
* gone are pruned on the way. Keep authenticated and guest traffic in
|
|
14
|
+
* separate pools so a private broadcast can never reach a guest connection.
|
|
15
|
+
*/
|
|
16
|
+
final class SocketPool
|
|
17
|
+
{
|
|
18
|
+
/** @var Socket[] */
|
|
19
|
+
private array $sockets = [];
|
|
20
|
+
|
|
21
|
+
public function count(): int
|
|
22
|
+
{
|
|
23
|
+
return count($this->sockets);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function add(Socket $socket): void
|
|
27
|
+
{
|
|
28
|
+
$this->sockets[] = $socket;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function discard(Socket $socket): void
|
|
32
|
+
{
|
|
33
|
+
$this->sockets = array_values(array_filter(
|
|
34
|
+
$this->sockets,
|
|
35
|
+
static fn (Socket $candidate): bool => $candidate !== $socket,
|
|
36
|
+
));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Send one value to everyone, pruning closed connections.
|
|
41
|
+
*/
|
|
42
|
+
public function broadcast(mixed $value): void
|
|
43
|
+
{
|
|
44
|
+
foreach ($this->sockets as $socket) {
|
|
45
|
+
if (!$socket->send($value)) {
|
|
46
|
+
$this->discard($socket);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Lib\Websocket;
|
|
6
|
+
|
|
7
|
+
use InvalidArgumentException;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The named-socket registry: what `pp.socket("name", ...)` connects to.
|
|
11
|
+
*
|
|
12
|
+
* The client connects with a name and nothing else, so socket names are
|
|
13
|
+
* unique application-wide and a duplicate is refused at registration time.
|
|
14
|
+
* Registrations live in `src/Lib/Websocket/sockets.php`, which the socket
|
|
15
|
+
* server loads on startup.
|
|
16
|
+
*/
|
|
17
|
+
final class SocketRegistry
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* @var array<string, array{
|
|
21
|
+
* name: string,
|
|
22
|
+
* handler: callable,
|
|
23
|
+
* requireAuth: bool,
|
|
24
|
+
* allowedRoles: string[],
|
|
25
|
+
* }>
|
|
26
|
+
*/
|
|
27
|
+
private static array $sockets = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a function as a named socket.
|
|
31
|
+
*
|
|
32
|
+
* The handler receives `(Socket $socket, array $args)`: the open
|
|
33
|
+
* connection and the argument payload from the connection's first frame.
|
|
34
|
+
*
|
|
35
|
+
* - `requireAuth: true` refuses the connection unless the handshake
|
|
36
|
+
* carries a valid auth cookie, before the handler runs.
|
|
37
|
+
* - `allowedRoles: [...]` adds RBAC on the verified payload, the same
|
|
38
|
+
* rule as `#[Exposed(allowedRoles: [...])]`.
|
|
39
|
+
*
|
|
40
|
+
* @param string[] $allowedRoles
|
|
41
|
+
*/
|
|
42
|
+
public static function register(
|
|
43
|
+
string $name,
|
|
44
|
+
callable $handler,
|
|
45
|
+
bool $requireAuth = false,
|
|
46
|
+
array $allowedRoles = [],
|
|
47
|
+
): void {
|
|
48
|
+
if ($name === '') {
|
|
49
|
+
throw new InvalidArgumentException('A socket needs a non-empty name.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isset(self::$sockets[$name])) {
|
|
53
|
+
throw new InvalidArgumentException(
|
|
54
|
+
"Two sockets are named `$name`. The client connects with a "
|
|
55
|
+
. 'name and nothing else, so socket names must be unique '
|
|
56
|
+
. 'application-wide.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
self::$sockets[$name] = [
|
|
61
|
+
'name' => $name,
|
|
62
|
+
'handler' => $handler,
|
|
63
|
+
'requireAuth' => $requireAuth,
|
|
64
|
+
'allowedRoles' => array_values($allowedRoles),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @return array{
|
|
70
|
+
* name: string,
|
|
71
|
+
* handler: callable,
|
|
72
|
+
* requireAuth: bool,
|
|
73
|
+
* allowedRoles: string[],
|
|
74
|
+
* }|null
|
|
75
|
+
*/
|
|
76
|
+
public static function get(string $name): ?array
|
|
77
|
+
{
|
|
78
|
+
return self::$sockets[$name] ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @return string[]
|
|
83
|
+
*/
|
|
84
|
+
public static function names(): array
|
|
85
|
+
{
|
|
86
|
+
return array_keys(self::$sockets);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Named-socket registrations: the server half of `pp.socket(...)`.
|
|
7
|
+
*
|
|
8
|
+
* This file is loaded once by `websocket-server.php` on startup. Register
|
|
9
|
+
* each socket the app serves here; the name the client connects with is the
|
|
10
|
+
* registered one, and names are unique application-wide.
|
|
11
|
+
*
|
|
12
|
+
* From the browser:
|
|
13
|
+
*
|
|
14
|
+
* ```js
|
|
15
|
+
* const sock = pp.socket("echo", { label: "you" }, {
|
|
16
|
+
* onMessage: (value) => append(value),
|
|
17
|
+
* });
|
|
18
|
+
* sock.send("hello");
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
use Lib\Websocket\Socket;
|
|
23
|
+
use Lib\Websocket\SocketPool;
|
|
24
|
+
use Lib\Websocket\SocketRegistry;
|
|
25
|
+
|
|
26
|
+
// A minimal example: echo every frame back, prefixed with the label the
|
|
27
|
+
// page opened the socket with.
|
|
28
|
+
SocketRegistry::register('echo', function (Socket $socket, array $args): void {
|
|
29
|
+
$label = is_string($args['label'] ?? null) ? $args['label'] : 'echo';
|
|
30
|
+
|
|
31
|
+
$socket->onMessage(function (mixed $value) use ($socket, $label): void {
|
|
32
|
+
$text = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
33
|
+
$socket->send("$label: $text");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// A broadcast example: everyone connected to `chat` hears everyone else.
|
|
38
|
+
$chatRoom = new SocketPool();
|
|
39
|
+
|
|
40
|
+
SocketRegistry::register('chat', function (Socket $socket, array $args) use ($chatRoom): void {
|
|
41
|
+
$chatRoom->add($socket);
|
|
42
|
+
|
|
43
|
+
$socket->onMessage(function (mixed $value) use ($chatRoom): void {
|
|
44
|
+
$chatRoom->broadcast($value);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
$socket->onClose(function () use ($chatRoom, $socket): void {
|
|
48
|
+
$chatRoom->discard($socket);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -54,7 +54,10 @@ if ($probe === false) {
|
|
|
54
54
|
fclose($probe);
|
|
55
55
|
|
|
56
56
|
// ── Build app ────────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
57
|
+
// Named-socket registrations: the functions `pp.socket("name", ...)` reaches.
|
|
58
|
+
require __DIR__ . '/sockets.php';
|
|
59
|
+
|
|
60
|
+
$manager = new ConnectionManager(); // The PulsePoint named-socket wire.
|
|
58
61
|
$server = IoServer::factory(
|
|
59
62
|
new HttpServer(new WsServer($manager)),
|
|
60
63
|
$port,
|
|
@@ -73,14 +76,18 @@ echo " Port: {$port}" . PHP_EOL;
|
|
|
73
76
|
echo " URL: {$url}" . PHP_EOL;
|
|
74
77
|
echo " PID: {$pid}" . PHP_EOL;
|
|
75
78
|
echo " Started: {$ts}" . PHP_EOL;
|
|
79
|
+
echo " Sockets: " . (implode(', ', \Lib\Websocket\SocketRegistry::names()) ?: '(none registered)') . PHP_EOL;
|
|
76
80
|
|
|
77
81
|
// ── Graceful shutdown & periodic logs (if loop available) ────────────────────
|
|
78
82
|
$loop = property_exists($server, 'loop') ? $server->loop : null;
|
|
79
83
|
if ($loop instanceof LoopInterface) {
|
|
84
|
+
// First-frame timeout and idle sweep need the loop's timers.
|
|
85
|
+
$manager->attachLoop($loop);
|
|
86
|
+
|
|
80
87
|
// Periodic stats every 60s
|
|
81
|
-
$loop->addPeriodicTimer(60, function () use ($ok) {
|
|
88
|
+
$loop->addPeriodicTimer(60, function () use ($ok, $manager) {
|
|
82
89
|
$mem = function_exists('memory_get_usage') ? number_format(memory_get_usage(true) / 1048576, 2) . ' MB' : 'n/a';
|
|
83
|
-
$msg = "✓ Heartbeat — memory: {$mem}";
|
|
90
|
+
$msg = "✓ Heartbeat — connections: {$manager->openConnectionCount()}, memory: {$mem}";
|
|
84
91
|
echo $ok($msg) . PHP_EOL;
|
|
85
92
|
});
|
|
86
93
|
|
package/dist/src/app/globals.css
CHANGED
package/dist/src/app/layout.php
CHANGED
|
@@ -15,7 +15,7 @@ MainLayout::$description = !empty(MainLayout::$description) ? MainLayout::$descr
|
|
|
15
15
|
<!-- Dynamic Header Scripts -->
|
|
16
16
|
</head>
|
|
17
17
|
|
|
18
|
-
<body
|
|
18
|
+
<body style="opacity:0;pointer-events:none;user-select:none;transition:opacity .18s ease-out;">
|
|
19
19
|
<?= MainLayout::$children; ?>
|
|
20
20
|
<!-- Dynamic Footer Scripts -->
|
|
21
21
|
</body>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use Firebase\JWT\JWT;
|
|
8
|
+
use Lib\Auth\Auth;
|
|
9
|
+
use PHPUnit\Framework\TestCase;
|
|
10
|
+
use PP\Env;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The JWT session tokens both wires trust: RPC auth reads them through the
|
|
14
|
+
* request session, and the socket server verifies the handshake cookie with
|
|
15
|
+
* `verifyToken(...)` alone.
|
|
16
|
+
*/
|
|
17
|
+
final class AuthTest extends TestCase
|
|
18
|
+
{
|
|
19
|
+
public function testSignInReturnsATokenThatVerifiesBackToThePayload(): void
|
|
20
|
+
{
|
|
21
|
+
$auth = Auth::getInstance();
|
|
22
|
+
|
|
23
|
+
$jwt = $auth->signIn('admin');
|
|
24
|
+
|
|
25
|
+
self::assertSame('admin', $auth->verifyToken($jwt));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public function testATamperedTokenVerifiesToNull(): void
|
|
29
|
+
{
|
|
30
|
+
$auth = Auth::getInstance();
|
|
31
|
+
$jwt = $auth->signIn('admin');
|
|
32
|
+
|
|
33
|
+
self::assertNull($auth->verifyToken($jwt . 'x'));
|
|
34
|
+
self::assertNull($auth->verifyToken('not-a-jwt'));
|
|
35
|
+
self::assertNull($auth->verifyToken(null));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function testAnExpiredTokenVerifiesToNull(): void
|
|
39
|
+
{
|
|
40
|
+
$expired = JWT::encode(
|
|
41
|
+
[Auth::PAYLOAD_NAME => 'admin', 'exp' => time() - 60],
|
|
42
|
+
Env::string('AUTH_SECRET', ''),
|
|
43
|
+
'HS256'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
self::assertNull(Auth::getInstance()->verifyToken($expired));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public function testATokenSignedWithADifferentSecretVerifiesToNull(): void
|
|
50
|
+
{
|
|
51
|
+
$forged = JWT::encode(
|
|
52
|
+
[Auth::PAYLOAD_NAME => 'admin', 'exp' => time() + 3600],
|
|
53
|
+
'a-different-32-byte-signing-secret-key',
|
|
54
|
+
'HS256'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
self::assertNull(Auth::getInstance()->verifyToken($forged));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use Lib\Auth\Auth;
|
|
8
|
+
use Lib\Websocket\ConnectionManager;
|
|
9
|
+
use Lib\Websocket\Socket;
|
|
10
|
+
use Lib\Websocket\SocketRegistry;
|
|
11
|
+
use PHPUnit\Framework\TestCase;
|
|
12
|
+
use Tests\Support\FakeConnection;
|
|
13
|
+
use Tests\Support\RequiresFeature;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The named-socket wire from the server's side: where a connection must go,
|
|
17
|
+
* what the first frame is, and which failures travel as the reserved
|
|
18
|
+
* `{"error": ...}` frame.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the client runtime's SocketClient tests: name in the `name` query
|
|
21
|
+
* parameter, arguments as the first JSON object frame, refusals as an error
|
|
22
|
+
* frame followed by a close.
|
|
23
|
+
*/
|
|
24
|
+
final class ConnectionManagerTest extends TestCase
|
|
25
|
+
{
|
|
26
|
+
use RequiresFeature;
|
|
27
|
+
|
|
28
|
+
/** @var array<string, string|false> */
|
|
29
|
+
private array $envBackup = [];
|
|
30
|
+
|
|
31
|
+
protected function setUp(): void
|
|
32
|
+
{
|
|
33
|
+
$this->requireFeature('websocket');
|
|
34
|
+
SocketRegistryTest::resetRegistry();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
protected function tearDown(): void
|
|
38
|
+
{
|
|
39
|
+
SocketRegistryTest::resetRegistry();
|
|
40
|
+
|
|
41
|
+
foreach ($this->envBackup as $name => $previous) {
|
|
42
|
+
if ($previous === false) {
|
|
43
|
+
putenv($name);
|
|
44
|
+
} else {
|
|
45
|
+
putenv("$name=$previous");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
$this->envBackup = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private function setEnv(string $name, string $value): void
|
|
52
|
+
{
|
|
53
|
+
if (!array_key_exists($name, $this->envBackup)) {
|
|
54
|
+
$this->envBackup[$name] = getenv($name);
|
|
55
|
+
}
|
|
56
|
+
putenv("$name=$value");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A connection from a dev-browser origin, upgraded and args sent. */
|
|
60
|
+
private function openWithArgs(
|
|
61
|
+
ConnectionManager $manager,
|
|
62
|
+
FakeConnection $conn,
|
|
63
|
+
array $args = [],
|
|
64
|
+
): void {
|
|
65
|
+
$manager->onOpen($conn);
|
|
66
|
+
$manager->onMessage($conn, json_encode($args === [] ? new \stdClass() : $args));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private function devConnection(string $name): FakeConnection
|
|
70
|
+
{
|
|
71
|
+
return new FakeConnection(
|
|
72
|
+
"/__pulsepoint/ws?name=$name",
|
|
73
|
+
['Origin' => 'http://localhost:3000'],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public function testUnknownNameIsRefusedWithAReadableErrorFrame(): void
|
|
78
|
+
{
|
|
79
|
+
$manager = new ConnectionManager();
|
|
80
|
+
$conn = $this->devConnection('nope');
|
|
81
|
+
|
|
82
|
+
$manager->onOpen($conn);
|
|
83
|
+
|
|
84
|
+
$frame = $conn->lastFrame();
|
|
85
|
+
self::assertIsArray($frame);
|
|
86
|
+
self::assertArrayHasKey('error', $frame);
|
|
87
|
+
self::assertStringContainsString('No socket named `nope`', $frame['error']);
|
|
88
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public function testMissingNameIsRefused(): void
|
|
92
|
+
{
|
|
93
|
+
$manager = new ConnectionManager();
|
|
94
|
+
$conn = new FakeConnection('/__pulsepoint/ws', ['Origin' => 'http://localhost:3000']);
|
|
95
|
+
|
|
96
|
+
$manager->onOpen($conn);
|
|
97
|
+
|
|
98
|
+
self::assertStringContainsString('named no socket', $conn->lastFrame()['error']);
|
|
99
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public function testArgumentsArriveAsTheFirstFrameAndReachTheHandler(): void
|
|
103
|
+
{
|
|
104
|
+
$received = null;
|
|
105
|
+
SocketRegistry::register('echo', function (Socket $socket, array $args) use (&$received): void {
|
|
106
|
+
$received = $args;
|
|
107
|
+
$socket->onMessage(fn (mixed $value) => $socket->send("you: $value"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
$manager = new ConnectionManager();
|
|
111
|
+
$conn = $this->devConnection('echo');
|
|
112
|
+
|
|
113
|
+
$this->openWithArgs($manager, $conn, ['room' => 'lobby']);
|
|
114
|
+
self::assertSame(['room' => 'lobby'], $received);
|
|
115
|
+
|
|
116
|
+
$manager->onMessage($conn, '"hello"');
|
|
117
|
+
self::assertSame('you: hello', $conn->lastFrame());
|
|
118
|
+
self::assertSame([], $conn->closeCodes, 'the conversation stays open');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public function testEmptyArgumentsObjectIsAccepted(): void
|
|
122
|
+
{
|
|
123
|
+
$received = null;
|
|
124
|
+
SocketRegistry::register('feed', function (Socket $socket, array $args) use (&$received): void {
|
|
125
|
+
$received = $args;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
$manager = new ConnectionManager();
|
|
129
|
+
$conn = $this->devConnection('feed');
|
|
130
|
+
|
|
131
|
+
$this->openWithArgs($manager, $conn);
|
|
132
|
+
|
|
133
|
+
self::assertSame([], $received);
|
|
134
|
+
self::assertSame([], $conn->closeCodes);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public function testANonObjectFirstFrameIsRefused(): void
|
|
138
|
+
{
|
|
139
|
+
SocketRegistry::register('echo', static function (): void {});
|
|
140
|
+
|
|
141
|
+
$manager = new ConnectionManager();
|
|
142
|
+
$conn = $this->devConnection('echo');
|
|
143
|
+
|
|
144
|
+
$manager->onOpen($conn);
|
|
145
|
+
$manager->onMessage($conn, '[1, 2]');
|
|
146
|
+
|
|
147
|
+
self::assertStringContainsString('not a JSON object', $conn->lastFrame()['error']);
|
|
148
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public function testAnUnreadableFrameTravelsBackAsAnErrorFrame(): void
|
|
152
|
+
{
|
|
153
|
+
SocketRegistry::register('echo', function (Socket $socket): void {
|
|
154
|
+
$socket->onMessage(static function (): void {});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
$manager = new ConnectionManager();
|
|
158
|
+
$conn = $this->devConnection('echo');
|
|
159
|
+
|
|
160
|
+
$this->openWithArgs($manager, $conn);
|
|
161
|
+
$manager->onMessage($conn, '{not json');
|
|
162
|
+
|
|
163
|
+
self::assertStringContainsString('could not read a frame', $conn->lastFrame()['error']);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public function testAnOversizedFrameClosesWith1009(): void
|
|
167
|
+
{
|
|
168
|
+
SocketRegistry::register('echo', static function (): void {});
|
|
169
|
+
|
|
170
|
+
$manager = new ConnectionManager();
|
|
171
|
+
$conn = $this->devConnection('echo');
|
|
172
|
+
|
|
173
|
+
$this->openWithArgs($manager, $conn);
|
|
174
|
+
$manager->onMessage($conn, json_encode(str_repeat('x', 5000)));
|
|
175
|
+
|
|
176
|
+
self::assertContains(1009, $conn->closeCodes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public function testProductionRefusesAnUnknownOriginBeforeAnyFrame(): void
|
|
180
|
+
{
|
|
181
|
+
SocketRegistry::register('echo', static function (): void {});
|
|
182
|
+
$this->setEnv('APP_ENV', 'production');
|
|
183
|
+
|
|
184
|
+
$manager = new ConnectionManager();
|
|
185
|
+
$conn = new FakeConnection(
|
|
186
|
+
'/__pulsepoint/ws?name=echo',
|
|
187
|
+
['Origin' => 'https://evil.example'],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
$manager->onOpen($conn);
|
|
191
|
+
|
|
192
|
+
self::assertSame([], $conn->sentFrames, 'refused silently, before the wire speaks');
|
|
193
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public function testProductionAcceptsAnOriginNamedInTheAllowlist(): void
|
|
197
|
+
{
|
|
198
|
+
SocketRegistry::register('echo', static function (): void {});
|
|
199
|
+
$this->setEnv('APP_ENV', 'production');
|
|
200
|
+
$this->setEnv('WEBSOCKET_ALLOWED_ORIGINS', 'https://app.example');
|
|
201
|
+
|
|
202
|
+
$manager = new ConnectionManager();
|
|
203
|
+
$conn = new FakeConnection(
|
|
204
|
+
'/__pulsepoint/ws?name=echo',
|
|
205
|
+
['Origin' => 'https://app.example'],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
$manager->onOpen($conn);
|
|
209
|
+
|
|
210
|
+
self::assertSame([], $conn->closeCodes);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public function testRequireAuthRefusesAGuestConnection(): void
|
|
214
|
+
{
|
|
215
|
+
SocketRegistry::register('private', static function (): void {}, requireAuth: true);
|
|
216
|
+
|
|
217
|
+
$manager = new ConnectionManager();
|
|
218
|
+
$conn = $this->devConnection('private');
|
|
219
|
+
|
|
220
|
+
$manager->onOpen($conn);
|
|
221
|
+
|
|
222
|
+
self::assertStringContainsString('signed-in session', $conn->lastFrame()['error']);
|
|
223
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public function testAValidAuthCookieReachesTheHandlerAsThePayload(): void
|
|
227
|
+
{
|
|
228
|
+
$seenPayload = false;
|
|
229
|
+
SocketRegistry::register('private', function (Socket $socket) use (&$seenPayload): void {
|
|
230
|
+
$seenPayload = $socket->payload();
|
|
231
|
+
}, requireAuth: true);
|
|
232
|
+
|
|
233
|
+
$jwt = Auth::getInstance()->signIn('admin');
|
|
234
|
+
|
|
235
|
+
$manager = new ConnectionManager();
|
|
236
|
+
$conn = new FakeConnection('/__pulsepoint/ws?name=private', [
|
|
237
|
+
'Origin' => 'http://localhost:3000',
|
|
238
|
+
'Cookie' => Auth::$cookieName . '=' . $jwt,
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
$this->openWithArgs($manager, $conn);
|
|
242
|
+
|
|
243
|
+
self::assertSame('admin', $seenPayload);
|
|
244
|
+
self::assertSame([], $conn->closeCodes);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public function testAHandlerExceptionTravelsAsTheErrorFrame(): void
|
|
248
|
+
{
|
|
249
|
+
SocketRegistry::register('broken', static function (): void {
|
|
250
|
+
throw new \RuntimeException('boom');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
$manager = new ConnectionManager();
|
|
254
|
+
$conn = $this->devConnection('broken');
|
|
255
|
+
|
|
256
|
+
$this->openWithArgs($manager, $conn);
|
|
257
|
+
|
|
258
|
+
$frame = $conn->lastFrame();
|
|
259
|
+
self::assertArrayHasKey('error', $frame);
|
|
260
|
+
self::assertStringContainsString('boom', $frame['error'], 'development shows the message');
|
|
261
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public function testCloseReleasesTheConnectionSlot(): void
|
|
265
|
+
{
|
|
266
|
+
SocketRegistry::register('echo', static function (): void {});
|
|
267
|
+
|
|
268
|
+
$manager = new ConnectionManager();
|
|
269
|
+
$conn = $this->devConnection('echo');
|
|
270
|
+
|
|
271
|
+
$this->openWithArgs($manager, $conn);
|
|
272
|
+
self::assertSame(1, $manager->openConnectionCount());
|
|
273
|
+
|
|
274
|
+
$manager->onClose($conn);
|
|
275
|
+
self::assertSame(0, $manager->openConnectionCount());
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use PHPUnit\Framework\TestCase;
|
|
8
|
+
use PP\Security\Csrf;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The CSRF half of the RPC wire: the `pp_csrf` cookie family and the
|
|
12
|
+
* double-submit validation of the `X-CSRF-Token` header.
|
|
13
|
+
*/
|
|
14
|
+
final class CsrfTest extends TestCase
|
|
15
|
+
{
|
|
16
|
+
protected function setUp(): void
|
|
17
|
+
{
|
|
18
|
+
$this->clearCsrfCookies();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
protected function tearDown(): void
|
|
22
|
+
{
|
|
23
|
+
$this->clearCsrfCookies();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private function clearCsrfCookies(): void
|
|
27
|
+
{
|
|
28
|
+
foreach (array_keys($_COOKIE) as $name) {
|
|
29
|
+
if (str_starts_with((string) $name, 'pp_csrf')) {
|
|
30
|
+
unset($_COOKIE[$name]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public function testCookieFamilyUsesThePpCsrfNamesTheRuntimeReads(): void
|
|
36
|
+
{
|
|
37
|
+
$names = Csrf::cookieNames();
|
|
38
|
+
|
|
39
|
+
self::assertNotEmpty($names);
|
|
40
|
+
self::assertSame('pp_csrf', end($names), 'plain pp_csrf is always the fallback name');
|
|
41
|
+
|
|
42
|
+
foreach ($names as $name) {
|
|
43
|
+
self::assertStringStartsWith('pp_csrf', $name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public function testEnsureCookieMintsASignedTokenForEveryCookieName(): void
|
|
48
|
+
{
|
|
49
|
+
Csrf::ensureCookie();
|
|
50
|
+
|
|
51
|
+
foreach (Csrf::cookieNames() as $name) {
|
|
52
|
+
self::assertArrayHasKey($name, $_COOKIE);
|
|
53
|
+
self::assertMatchesRegularExpression(
|
|
54
|
+
'/^[0-9a-f]{32}\.[0-9a-f]{64}$/',
|
|
55
|
+
$_COOKIE[$name],
|
|
56
|
+
'token is nonce.hmac-sha256'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$values = array_map(static fn (string $name) => $_COOKIE[$name], Csrf::cookieNames());
|
|
61
|
+
self::assertCount(1, array_unique($values), 'every issued name carries the same token');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public function testEnsureCookieKeepsAnExistingValidToken(): void
|
|
65
|
+
{
|
|
66
|
+
Csrf::ensureCookie();
|
|
67
|
+
$first = $_COOKIE['pp_csrf'];
|
|
68
|
+
|
|
69
|
+
Csrf::ensureCookie();
|
|
70
|
+
|
|
71
|
+
self::assertSame($first, $_COOKIE['pp_csrf']);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public function testRotateReplacesTheToken(): void
|
|
75
|
+
{
|
|
76
|
+
Csrf::ensureCookie();
|
|
77
|
+
$first = $_COOKIE['pp_csrf'];
|
|
78
|
+
|
|
79
|
+
Csrf::rotate();
|
|
80
|
+
|
|
81
|
+
self::assertNotSame($first, $_COOKIE['pp_csrf']);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public function testValidTokenRoundTrip(): void
|
|
85
|
+
{
|
|
86
|
+
Csrf::ensureCookie();
|
|
87
|
+
|
|
88
|
+
self::assertNull(Csrf::validateHeaderToken($_COOKIE['pp_csrf']));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public function testMissingHeaderTokenIsRefused(): void
|
|
92
|
+
{
|
|
93
|
+
Csrf::ensureCookie();
|
|
94
|
+
|
|
95
|
+
self::assertSame('Missing CSRF token', Csrf::validateHeaderToken(''));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public function testHeaderThatMatchesNoCookieIsRefused(): void
|
|
99
|
+
{
|
|
100
|
+
Csrf::ensureCookie();
|
|
101
|
+
|
|
102
|
+
self::assertSame(
|
|
103
|
+
'Invalid CSRF token',
|
|
104
|
+
Csrf::validateHeaderToken(str_repeat('a', 32) . '.' . str_repeat('b', 64))
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public function testTamperedSignatureIsRefusedEvenWhenTheCookieMatches(): void
|
|
109
|
+
{
|
|
110
|
+
// Double-submit alone is not enough: the signature must verify, so a
|
|
111
|
+
// token an attacker planted (cookie tossing) without the secret fails.
|
|
112
|
+
$forged = str_repeat('a', 32) . '.' . str_repeat('b', 64);
|
|
113
|
+
foreach (Csrf::cookieNames() as $name) {
|
|
114
|
+
$_COOKIE[$name] = $forged;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
self::assertSame('Invalid CSRF token', Csrf::validateHeaderToken($forged));
|
|
118
|
+
}
|
|
119
|
+
}
|