create-prisma-php-app 5.0.0-alpha.8 → 5.0.0

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.
Files changed (60) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +191 -0
  3. package/dist/AGENTS.md +588 -193
  4. package/dist/bootstrap.php +426 -213
  5. package/dist/index.js +2 -2
  6. package/dist/phpunit.xml +25 -0
  7. package/dist/postcss.config.js +4 -2
  8. package/dist/prisma-php.js +2 -2
  9. package/dist/public/.htaccess +1 -1
  10. package/dist/public/js/main.js +13 -1
  11. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  12. package/dist/settings/auto-swagger-docs.ts +10 -10
  13. package/dist/settings/bs-config.ts +47 -27
  14. package/dist/settings/build.ts +2 -32
  15. package/dist/settings/component-map.ts +476 -0
  16. package/dist/settings/files-list.ts +2 -2
  17. package/dist/settings/project-name.ts +3 -6
  18. package/dist/settings/restart-mcp.ts +2 -2
  19. package/dist/settings/restart-websocket.ts +2 -2
  20. package/dist/settings/run-postcss.ts +205 -0
  21. package/dist/settings/run-tests.ts +35 -0
  22. package/dist/settings/swagger-config.ts +3 -3
  23. package/dist/settings/utils.ts +3 -3
  24. package/dist/src/Lib/Auth/Auth.php +62 -34
  25. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  26. package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
  27. package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
  28. package/dist/src/Lib/Websocket/Socket.php +170 -0
  29. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  30. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  31. package/dist/src/Lib/Websocket/sockets.php +50 -0
  32. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  33. package/dist/src/app/globals.css +3 -1
  34. package/dist/src/app/layout.php +1 -1
  35. package/dist/tests/AuthTest.php +59 -0
  36. package/dist/tests/ConnectionManagerTest.php +277 -0
  37. package/dist/tests/CsrfTest.php +119 -0
  38. package/dist/tests/DeferComponentRootsTest.php +147 -0
  39. package/dist/tests/FeaturesTest.php +41 -0
  40. package/dist/tests/README.md +119 -0
  41. package/dist/tests/RpcWireContractTest.php +101 -0
  42. package/dist/tests/SocketPoolTest.php +69 -0
  43. package/dist/tests/SocketRegistryTest.php +77 -0
  44. package/dist/tests/SocketTest.php +124 -0
  45. package/dist/tests/SocketsRegistrationTest.php +40 -0
  46. package/dist/tests/Support/FakeConnection.php +62 -0
  47. package/dist/tests/Support/Features.php +38 -0
  48. package/dist/tests/Support/RequiresFeature.php +26 -0
  49. package/dist/tests/bootstrap.php +44 -0
  50. package/dist/ts/main.ts +21 -3
  51. package/dist/ts/tailwind-merge.ts +13 -0
  52. package/package.json +4 -4
  53. package/dist/README.md +0 -213
  54. package/dist/public/js/pp-reactive-v2.js +0 -1
  55. package/dist/settings/bs-config.json +0 -6
  56. package/dist/settings/class-imports.ts +0 -165
  57. package/dist/settings/class-log.ts +0 -244
  58. package/dist/settings/component-import-checker.ts +0 -90
  59. package/dist/settings/files-list.json +0 -1
  60. 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
- $manager = new ConnectionManager(); // your app component
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
 
@@ -1,4 +1,6 @@
1
- @import "tailwindcss";
1
+ @import "tailwindcss" source(none);
2
+ @source "../";
3
+ @source "../../ts";
2
4
 
3
5
  :root {
4
6
  --background: #ffffff;
@@ -15,7 +15,7 @@ MainLayout::$description = !empty(MainLayout::$description) ? MainLayout::$descr
15
15
  <!-- Dynamic Header Scripts -->
16
16
  </head>
17
17
 
18
- <body pp-spa="true" style="opacity:0;pointer-events:none;user-select:none;transition:opacity .18s ease-out;">
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
+ }