create-prisma-php-app 5.1.0-alpha.23 → 5.1.0-alpha.25
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 +21 -1
- package/dist/AGENTS.md +36 -20
- package/dist/bootstrap.php +205 -187
- package/dist/index.js +1 -1
- package/dist/settings/bs-config.ts +44 -1
- package/dist/settings/run-tests.ts +35 -0
- package/dist/src/Lib/Auth/Auth.php +12 -25
- package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
- 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/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +118 -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/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use PHPUnit\Framework\TestCase;
|
|
8
|
+
use Tests\Support\Features;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The suite's own feature awareness: flags come from `prisma-php.json`, the
|
|
12
|
+
* single source of truth for which optional scaffolds exist in this app.
|
|
13
|
+
*/
|
|
14
|
+
final class FeaturesTest extends TestCase
|
|
15
|
+
{
|
|
16
|
+
public function testConfigIsReadFromPrismaPhpJson(): void
|
|
17
|
+
{
|
|
18
|
+
$raw = json_decode((string) file_get_contents(DOCUMENT_PATH . '/prisma-php.json'), true);
|
|
19
|
+
|
|
20
|
+
self::assertIsArray($raw);
|
|
21
|
+
self::assertSame($raw, Features::config());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function testEnabledMirrorsTheBooleanFlags(): void
|
|
25
|
+
{
|
|
26
|
+
$config = Features::config();
|
|
27
|
+
|
|
28
|
+
foreach (['websocket', 'mcp', 'swaggerDocs', 'prisma', 'tailwindcss', 'typescript'] as $flag) {
|
|
29
|
+
self::assertSame(
|
|
30
|
+
($config[$flag] ?? false) === true,
|
|
31
|
+
Features::enabled($flag),
|
|
32
|
+
"Features::enabled('$flag') mirrors prisma-php.json"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public function testAnUnknownFlagIsDisabled(): void
|
|
38
|
+
{
|
|
39
|
+
self::assertFalse(Features::enabled('not-a-real-feature'));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# App tests
|
|
2
|
+
|
|
3
|
+
PHPUnit tests for the **application** code — `src/Lib/**`, the app's wire
|
|
4
|
+
contracts, and `src/Lib/Websocket/sockets.php` registrations — not the Prisma
|
|
5
|
+
PHP framework under `vendor/tsnc/prisma-php` (that package carries its own
|
|
6
|
+
suite in its source repo).
|
|
7
|
+
|
|
8
|
+
## The command
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm run test
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That runs PHPUnit through `settings/run-tests.ts`, which uses the PHP binary
|
|
15
|
+
from `prisma-php.json` (`phpRootPathExe`) so the suite runs with the same PHP
|
|
16
|
+
the app runs on. It exits non-zero on any failure, so CI, a pre-commit hook,
|
|
17
|
+
or an agent knows exactly when something broke.
|
|
18
|
+
|
|
19
|
+
Narrow a run with PHPUnit's own flags after `--`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm run test -- --filter CsrfTest
|
|
23
|
+
npm run test -- --filter testValidTokenRoundTrip
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Running PHPUnit directly also works: `php vendor/phpunit/phpunit/phpunit`
|
|
27
|
+
(configuration is picked up from `phpunit.xml` at the project root).
|
|
28
|
+
|
|
29
|
+
## Layout
|
|
30
|
+
|
|
31
|
+
One file per surface, flat in `tests/`:
|
|
32
|
+
|
|
33
|
+
| File | Surface |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `bootstrap.php` | Shared setup: autoloader, path constants, deterministic test env |
|
|
36
|
+
| `Support/` | Reusable fakes (`Tests\Support\...`, autoloaded via composer `autoload-dev`) |
|
|
37
|
+
| `Support/Features.php` | Feature flags read from `prisma-php.json` |
|
|
38
|
+
| `Support/RequiresFeature.php` | Skip guard for tests of optional features |
|
|
39
|
+
| `FeaturesTest.php` | The suite's own feature awareness mirrors `prisma-php.json` |
|
|
40
|
+
| `CsrfTest.php` | The `pp_csrf` cookie family and `X-CSRF-Token` validation |
|
|
41
|
+
| `RpcWireContractTest.php` | PulsePoint wire headers (`$isRpc` / `$isNavigation` / `$isWire`), including the pin that the legacy header stays dead |
|
|
42
|
+
| `SocketTest.php` | One socket connection: JSON frames, reserved error shape, close semantics |
|
|
43
|
+
| `SocketRegistryTest.php` | Named-socket registration and duplicate refusal |
|
|
44
|
+
| `SocketPoolTest.php` | Broadcast and pruning |
|
|
45
|
+
| `ConnectionManagerTest.php` | The full named-socket wire: handshake refusals, argument frame, auth, limits |
|
|
46
|
+
| `SocketsRegistrationTest.php` | The app's real `sockets.php` loads and names its sockets |
|
|
47
|
+
| `AuthTest.php` | JWT round trip, tamper/expiry/foreign-secret rejection |
|
|
48
|
+
|
|
49
|
+
## The test environment is deterministic
|
|
50
|
+
|
|
51
|
+
`tests/bootstrap.php` does **not** load the real `.env`. It loads the composer
|
|
52
|
+
autoloader plus `settings/paths.php`, then sets deterministic defaults
|
|
53
|
+
(`APP_ENV=development`, a 32-byte `AUTH_SECRET`, `FUNCTION_CALL_SECRET`) only
|
|
54
|
+
when the variable is not already set — so the suite behaves the same on any
|
|
55
|
+
machine, never depends on real secrets, and CI can still override. The Prisma
|
|
56
|
+
PHP `bootstrap.php` at the project root (route resolution, rendering, CSRF
|
|
57
|
+
cookie issuing) is **not** booted; tests exercise app classes directly.
|
|
58
|
+
|
|
59
|
+
A test that needs a different env value must set it with `putenv(...)` and
|
|
60
|
+
restore the previous value in `tearDown()` (`PP\Env` reads `getenv()` first) —
|
|
61
|
+
see `ConnectionManagerTest::setEnv()` for the pattern.
|
|
62
|
+
|
|
63
|
+
## Optional features are optional in the suite too
|
|
64
|
+
|
|
65
|
+
`prisma-php.json` decides which optional scaffolds exist (`websocket`, `mcp`,
|
|
66
|
+
`swaggerDocs`, `prisma`, ...). A test for an optional feature must guard on
|
|
67
|
+
the flag, or a freshly generated app without that feature fatals instead of
|
|
68
|
+
passing:
|
|
69
|
+
|
|
70
|
+
```php
|
|
71
|
+
use Tests\Support\RequiresFeature;
|
|
72
|
+
|
|
73
|
+
final class MySocketTest extends TestCase
|
|
74
|
+
{
|
|
75
|
+
use RequiresFeature;
|
|
76
|
+
|
|
77
|
+
protected function setUp(): void
|
|
78
|
+
{
|
|
79
|
+
$this->requireFeature('websocket'); // FIRST line, before touching the scaffold
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
When the flag is off the test reports itself as skipped with the reason and
|
|
85
|
+
the enable command — never as a failure. `tearDown()` still runs after a
|
|
86
|
+
skip, so teardown code must not assume the feature's classes exist (see
|
|
87
|
+
`SocketRegistryTest::resetRegistry()` for the `class_exists` guard).
|
|
88
|
+
|
|
89
|
+
The websocket suites (`SocketTest`, `SocketRegistryTest`, `SocketPoolTest`,
|
|
90
|
+
`ConnectionManagerTest`, `SocketsRegistrationTest`) are gated this way. Core
|
|
91
|
+
surfaces — CSRF, the RPC wire headers, auth — are always-on and never gated.
|
|
92
|
+
|
|
93
|
+
## Conventions
|
|
94
|
+
|
|
95
|
+
- one `SomethingTest.php` per surface, in the `Tests` namespace; shared fakes
|
|
96
|
+
live in `tests/Support` under `Tests\Support`
|
|
97
|
+
- static registries (`SocketRegistry`) are reset in `setUp()`/`tearDown()` via
|
|
98
|
+
reflection so tests never depend on each other's order
|
|
99
|
+
- superglobals a test touches (`$_SERVER`, `$_COOKIE`, env) are backed up in
|
|
100
|
+
`setUp()` and restored in `tearDown()`
|
|
101
|
+
- wire tests assert **frames and close codes**, not internals:
|
|
102
|
+
`Tests\Support\FakeConnection` records everything the server sends, so a
|
|
103
|
+
test reads the conversation the browser would have seen
|
|
104
|
+
- expected framework failures are part of the contract — a refusal test
|
|
105
|
+
asserts the readable `{"error": "..."}` frame (or HTTP status), not just
|
|
106
|
+
"something failed"
|
|
107
|
+
- tests cover app-level code and the wire contracts the app depends on; do
|
|
108
|
+
not test Prisma PHP internals from here — those belong to the package
|
|
109
|
+
repo's own suite (`vendor/tsnc/prisma-php/tests` when vendored)
|
|
110
|
+
|
|
111
|
+
## For AI agents
|
|
112
|
+
|
|
113
|
+
Read this file plus `node_modules/prisma-php/dist/docs/testing.md` before
|
|
114
|
+
adding or changing tests. When you add an app feature with logic worth
|
|
115
|
+
protecting — an exposed function's validation, a socket handler, an auth
|
|
116
|
+
rule — add the matching `*Test.php` here in the same change, and run
|
|
117
|
+
`npm run test` before declaring the work done. A red suite is a finding to
|
|
118
|
+
fix, not to silence.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use PHPUnit\Framework\TestCase;
|
|
8
|
+
use PP\Request;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The PulsePoint wire headers, as the app depends on them.
|
|
12
|
+
*
|
|
13
|
+
* `pp.rpc(...)` posts with `X-PP-RPC: true` + `X-PP-Function`; SPA navigation
|
|
14
|
+
* fetches with `X-PP-Navigation: true`; both carry `X-PulsePoint-Wire: true`.
|
|
15
|
+
* This is a complete migration — the legacy `PP-Wire-Request` header must NOT
|
|
16
|
+
* be recognized any more, and that is pinned here on purpose.
|
|
17
|
+
*/
|
|
18
|
+
final class RpcWireContractTest extends TestCase
|
|
19
|
+
{
|
|
20
|
+
private array $serverBackup = [];
|
|
21
|
+
|
|
22
|
+
protected function setUp(): void
|
|
23
|
+
{
|
|
24
|
+
$this->serverBackup = $_SERVER;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected function tearDown(): void
|
|
28
|
+
{
|
|
29
|
+
$_SERVER = $this->serverBackup;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private function initRequest(array $server): void
|
|
33
|
+
{
|
|
34
|
+
$_SERVER = array_merge([
|
|
35
|
+
'HTTP_HOST' => 'example.test',
|
|
36
|
+
'SCRIPT_NAME' => '/index.php',
|
|
37
|
+
'REMOTE_ADDR' => '127.0.0.1',
|
|
38
|
+
], $server);
|
|
39
|
+
|
|
40
|
+
Request::init();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public function testRpcPostIsDetected(): void
|
|
44
|
+
{
|
|
45
|
+
$this->initRequest([
|
|
46
|
+
'REQUEST_METHOD' => 'POST',
|
|
47
|
+
'CONTENT_TYPE' => 'application/json',
|
|
48
|
+
'HTTP_X_PULSEPOINT_WIRE' => 'true',
|
|
49
|
+
'HTTP_X_PP_RPC' => 'true',
|
|
50
|
+
'HTTP_X_PP_FUNCTION' => 'saveProfile',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
self::assertTrue(Request::$isWire);
|
|
54
|
+
self::assertTrue(Request::$isRpc);
|
|
55
|
+
self::assertFalse(Request::$isNavigation);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public function testNavigationGetIsWireButNotRpc(): void
|
|
59
|
+
{
|
|
60
|
+
$this->initRequest([
|
|
61
|
+
'REQUEST_METHOD' => 'GET',
|
|
62
|
+
'HTTP_X_PULSEPOINT_WIRE' => 'true',
|
|
63
|
+
'HTTP_X_PP_NAVIGATION' => 'true',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
self::assertTrue(Request::$isWire);
|
|
67
|
+
self::assertFalse(Request::$isRpc);
|
|
68
|
+
self::assertTrue(Request::$isNavigation);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public function testRpcHeaderOnAGetIsNotAnRpc(): void
|
|
72
|
+
{
|
|
73
|
+
$this->initRequest([
|
|
74
|
+
'REQUEST_METHOD' => 'GET',
|
|
75
|
+
'HTTP_X_PP_RPC' => 'true',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
self::assertFalse(Request::$isRpc);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public function testPlainRequestCarriesNoWireFlags(): void
|
|
82
|
+
{
|
|
83
|
+
$this->initRequest(['REQUEST_METHOD' => 'GET']);
|
|
84
|
+
|
|
85
|
+
self::assertFalse(Request::$isWire);
|
|
86
|
+
self::assertFalse(Request::$isRpc);
|
|
87
|
+
self::assertFalse(Request::$isNavigation);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public function testLegacyWireHeaderIsNoLongerRecognized(): void
|
|
91
|
+
{
|
|
92
|
+
$this->initRequest([
|
|
93
|
+
'REQUEST_METHOD' => 'POST',
|
|
94
|
+
'CONTENT_TYPE' => 'application/json',
|
|
95
|
+
'HTTP_PP_WIRE_REQUEST' => 'true',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
self::assertFalse(Request::$isWire, 'complete migration: no legacy header support');
|
|
99
|
+
self::assertFalse(Request::$isRpc);
|
|
100
|
+
}
|
|
101
|
+
}
|