laravel-workers 0.4.0 → 0.4.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/LICENSE +21 -21
- package/bin/laravel-workers.mjs +141 -141
- package/build.mjs +2599 -2599
- package/compiler/bin/controller.mjs +92 -92
- package/kernel/cache.mjs +405 -405
- package/kernel/db.mjs +703 -703
- package/kernel/encrypter.mjs +1 -1
- package/kernel/hash.mjs +113 -113
- package/kernel/multipart.mjs +161 -161
- package/kernel/router.mjs +276 -276
- package/kernel/scheduler.mjs +236 -236
- package/kernel/str.mjs +445 -445
- package/kernel/tests/cache.test.mjs +290 -290
- package/kernel/tests/db.test.mjs +1068 -1068
- package/kernel/tests/encrypter.test.mjs +1 -1
- package/kernel/tests/hash.test.mjs +149 -149
- package/kernel/tests/http.test.mjs +1182 -1182
- package/kernel/tests/php-json.test.mjs +263 -263
- package/kernel/tests/router.test.mjs +281 -281
- package/kernel/tests/services.test.mjs +474 -474
- package/kernel/tests/str.test.mjs +83 -83
- package/kernel/tests/validation.test.mjs +340 -340
- package/kernel/validation.mjs +123 -123
- package/package.json +69 -69
- package/tools/schema-dump.php +158 -158
|
@@ -1,1182 +1,1182 @@
|
|
|
1
|
-
// kernel/tests/http.test.mjs — compiled HTTP pipeline parity vs PHP 8.3.
|
|
2
|
-
//
|
|
3
|
-
// Two independent proofs:
|
|
4
|
-
//
|
|
5
|
-
// 1. parsePhpQuery: the compiled rung's `a[b][c]`/`a[]` parser compared
|
|
6
|
-
// against the live PHP 8.3 `parse_str` for a broad case table. Anything
|
|
7
|
-
// that diverges is either fixed or pinned as an explicit KNOWN_DIVERGENCE
|
|
8
|
-
// (never silently tolerated).
|
|
9
|
-
//
|
|
10
|
-
// 2. The 11-step pipeline scenario: tools/php-http-pipeline-oracle.php boots a
|
|
11
|
-
// real Laravel 13.37 web pipeline (EncryptCookies → AddQueuedCookiesToResponse
|
|
12
|
-
// → StartSession → PreventRequestForgery → route middleware) over a dedicated
|
|
13
|
-
// sqlite database and replays a cookie-jar session that covers a fresh
|
|
14
|
-
// session, a CSRF failure, a JSON/HTML unauthenticated redirect, a login
|
|
15
|
-
// (session id + CSRF token regeneration), a guest redirect and a logout.
|
|
16
|
-
// The compiled `handleHttp` replays the exact same steps and both sides are
|
|
17
|
-
// compared per step:
|
|
18
|
-
//
|
|
19
|
-
// - status (exact)
|
|
20
|
-
// - response headers minus `date`/`set-cookie` (exact)
|
|
21
|
-
// - Set-Cookie per cookie: name + attribute list in order (the `Max-Age`
|
|
22
|
-
// *value* is normalised: Symfony derives it from real time(), which the
|
|
23
|
-
// frozen-clock oracle cannot reproduce — the `expires` value is asserted
|
|
24
|
-
// exactly instead)
|
|
25
|
-
// - body: JSON parsed + deepEqual; redirect bodies byte-exact; the 419
|
|
26
|
-
// debug body (absolute file/line/trace) is compared status + message
|
|
27
|
-
// - session row columns, and the raw session payload *bytes* after
|
|
28
|
-
// normalising the two random values (session id, CSRF token)
|
|
29
|
-
//
|
|
30
|
-
// Every random value is one of exactly two: the session id (random 40 chars) and
|
|
31
|
-
// the CSRF token (random 40 chars). Both are fed deterministically to the
|
|
32
|
-
// compiled side and normalised out of the payload comparison on both sides.
|
|
33
|
-
import { test } from 'node:test';
|
|
34
|
-
import assert from 'node:assert/strict';
|
|
35
|
-
import { spawnSync } from 'node:child_process';
|
|
36
|
-
import { existsSync } from 'node:fs';
|
|
37
|
-
import path from 'node:path';
|
|
38
|
-
import { fileURLToPath } from 'node:url';
|
|
39
|
-
import { createCookieEncryptor } from '../cookies.mjs';
|
|
40
|
-
import { handleHttp, parsePhpQuery, buildRequest } from '../http.mjs';
|
|
41
|
-
import { createDatabaseCache } from '../cache.mjs';
|
|
42
|
-
import { applyAuthenticatable } from '../auth.mjs';
|
|
43
|
-
import { Model } from '../model.mjs';
|
|
44
|
-
import { normalizeTable } from '../router.mjs';
|
|
45
|
-
import { oracleAppRoot, oracleSkip } from './oracle-app.mjs';
|
|
46
|
-
|
|
47
|
-
let DatabaseSync = null;
|
|
48
|
-
try {
|
|
49
|
-
({ DatabaseSync } = await import('node:sqlite'));
|
|
50
|
-
} catch {
|
|
51
|
-
// Older Node without node:sqlite: the pipeline section skips.
|
|
52
|
-
}
|
|
53
|
-
const noSqlite = DatabaseSync ? false : 'node:sqlite unavailable (needs Node >= 22)';
|
|
54
|
-
|
|
55
|
-
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
56
|
-
const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-http-pipeline-oracle.php');
|
|
57
|
-
const PHP = process.env.PHP_BIN || 'php';
|
|
58
|
-
const APP_ROOT = oracleAppRoot();
|
|
59
|
-
// The PHP oracle parity tests need a real Laravel app (PHP 8.3 + vendor/).
|
|
60
|
-
// Without one they SKIP (the CI `test` job has no app; the `e2e` job points
|
|
61
|
-
// LW_LARAVEL_APP at the app it creates and runs them for real).
|
|
62
|
-
const noApp = APP_ROOT ? false : oracleSkip('http.test.mjs');
|
|
63
|
-
|
|
64
|
-
const FREEZE_MS = 1792336827000;
|
|
65
|
-
const FREEZE_S = Math.floor(FREEZE_MS / 1000);
|
|
66
|
-
const HASH12 = '$2y$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
|
|
67
|
-
const CLIENT_IP = '127.0.0.1';
|
|
68
|
-
const USER_AGENT = 'Symfony';
|
|
69
|
-
const BASE = 'http://localhost';
|
|
70
|
-
const TOKEN = '\u00abTOKEN\u00bb';
|
|
71
|
-
|
|
72
|
-
function requireApp() {
|
|
73
|
-
if (!APP_ROOT || !existsSync(path.join(APP_ROOT, 'vendor', 'autoload.php'))) {
|
|
74
|
-
throw new Error(oracleSkip('http.test.mjs'));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// 1. parsePhpQuery vs the live PHP 8.3 parse_str
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
//
|
|
82
|
-
// MATCH_CASES must be byte-identical on both sides. KNOWN_DIVERGENCES are
|
|
83
|
-
// asserted to *stay* divergent (pinned to PHP's output) so a future change that
|
|
84
|
-
// silently widens or narrows the gap fails the suite.
|
|
85
|
-
const MATCH_CASES = [
|
|
86
|
-
'a=1&b=2',
|
|
87
|
-
'a[]=1&a[]=2',
|
|
88
|
-
'a[b]=1&a[b][c]=2',
|
|
89
|
-
'a[b][c]=2&a[b]=1',
|
|
90
|
-
'a.b=1',
|
|
91
|
-
'a b=2',
|
|
92
|
-
'a=1&a=2',
|
|
93
|
-
'x=0&x[]=1',
|
|
94
|
-
'x[]=1&x=s',
|
|
95
|
-
'a%5Bb%5D=1',
|
|
96
|
-
'n=+1',
|
|
97
|
-
'e=',
|
|
98
|
-
'k=1&k=2&k[]=3',
|
|
99
|
-
'arr[0]=a&arr[1]=b',
|
|
100
|
-
'arr[]=a&arr[2]=b',
|
|
101
|
-
'a[b][]=1&a[b][]=2',
|
|
102
|
-
'a[b]=1&a=2',
|
|
103
|
-
'a=2&a[b]=1',
|
|
104
|
-
'x[1][2]=3',
|
|
105
|
-
'a=1&&b=2',
|
|
106
|
-
'a[]=1&a[0]=9',
|
|
107
|
-
'A=1&a=2',
|
|
108
|
-
'a[0][0]=1',
|
|
109
|
-
'foo=bar%20baz',
|
|
110
|
-
'arr[a][b][c][d]=1',
|
|
111
|
-
'k[]=1&k[a]=2',
|
|
112
|
-
'a=b%26c',
|
|
113
|
-
'k[n][]=1',
|
|
114
|
-
'k[][]=1',
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
// PHP's parse_str walks the *decoded* name as a C string, so a NUL byte (%00)
|
|
118
|
-
// truncates the variable name. The compiled rung keeps the NUL instead
|
|
119
|
-
// (it is a JS string, not a NUL-terminated buffer). The direction is
|
|
120
|
-
// fail-closed: `_token%00x=...` is *not* seen as `_token` by the compiled rung,
|
|
121
|
-
// so the CSRF check rejects the request rather than accepting it. Pinned here
|
|
122
|
-
// so the divergence stays explicit and is never widened by accident.
|
|
123
|
-
const NUL_CASES = [
|
|
124
|
-
['a%00b=1', { a: '1' }],
|
|
125
|
-
['%00a=1', []],
|
|
126
|
-
['a[b%00c]=1', { a_b: '1' }],
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
function phpParseStr(cases) {
|
|
130
|
-
const code = [
|
|
131
|
-
'$cases = json_decode(stream_get_contents(STDIN), true);',
|
|
132
|
-
'$out = [];',
|
|
133
|
-
'foreach ($cases as $c) { parse_str($c, $r); $out[] = $r; }',
|
|
134
|
-
'echo json_encode($out);',
|
|
135
|
-
].join(' ');
|
|
136
|
-
const res = spawnSync(PHP, ['-r', code], {
|
|
137
|
-
input: JSON.stringify(cases),
|
|
138
|
-
encoding: 'utf8',
|
|
139
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
140
|
-
});
|
|
141
|
-
if (res.error || res.status !== 0) {
|
|
142
|
-
throw new Error(`php parse_str probe failed: ${res.error?.message ?? res.stderr}`);
|
|
143
|
-
}
|
|
144
|
-
return JSON.parse(res.stdout);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
test('parsePhpQuery matches PHP 8.3 parse_str exactly', () => {
|
|
148
|
-
const php = phpParseStr(MATCH_CASES);
|
|
149
|
-
for (let i = 0; i < MATCH_CASES.length; i += 1) {
|
|
150
|
-
const js = JSON.parse(JSON.stringify(parsePhpQuery(MATCH_CASES[i])));
|
|
151
|
-
assert.deepEqual(js, php[i], `parsePhpQuery diverged for ${MATCH_CASES[i]}`);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('parsePhpQuery NUL-in-name divergence is explicit and fail-closed', () => {
|
|
156
|
-
const cases = NUL_CASES.map(([c]) => c);
|
|
157
|
-
const php = phpParseStr(cases);
|
|
158
|
-
for (let i = 0; i < cases.length; i += 1) {
|
|
159
|
-
const js = JSON.parse(JSON.stringify(parsePhpQuery(cases[i])));
|
|
160
|
-
// PHP's value is pinned: it is the C-string-truncation artifact.
|
|
161
|
-
assert.deepEqual(php[i], NUL_CASES[i][1], `pinned PHP output drifted for ${cases[i]}`);
|
|
162
|
-
assert.notDeepEqual(
|
|
163
|
-
js,
|
|
164
|
-
php[i],
|
|
165
|
-
`parsePhpQuery now matches PHP for ${cases[i]} — move it to MATCH_CASES and update MAP.md`
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// 2. the pipeline scenario
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
//
|
|
174
|
-
// Keep this table/labels in lockstep with tools/php-http-pipeline-oracle.php:
|
|
175
|
-
// the oracle registers exactly these routes and probes only these observation
|
|
176
|
-
// points, so the compiled handlers below are the same functions in JS.
|
|
177
|
-
const STEPS = [
|
|
178
|
-
{ label: '1 GET state anon', method: 'GET', uri: '/__probe/state' },
|
|
179
|
-
{ label: '2 GET state jar', method: 'GET', uri: '/__probe/state', jar: true },
|
|
180
|
-
{ label: '3 POST state no csrf', method: 'POST', uri: '/__probe/state', jar: true },
|
|
181
|
-
{
|
|
182
|
-
label: '4 POST state csrf',
|
|
183
|
-
method: 'POST',
|
|
184
|
-
uri: '/__probe/state',
|
|
185
|
-
jar: true,
|
|
186
|
-
form: { _token: '@token' },
|
|
187
|
-
},
|
|
188
|
-
{ label: '5 GET auth json', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
189
|
-
{
|
|
190
|
-
label: '6 GET auth html',
|
|
191
|
-
method: 'GET',
|
|
192
|
-
uri: '/__probe/auth',
|
|
193
|
-
jar: true,
|
|
194
|
-
headers: { Accept: 'text/html' },
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
label: '7 POST login',
|
|
198
|
-
method: 'POST',
|
|
199
|
-
uri: '/__probe/login',
|
|
200
|
-
jar: true,
|
|
201
|
-
form: { _token: '@token', email: 'u@example.com', password: 'secret' },
|
|
202
|
-
},
|
|
203
|
-
{ label: '8 GET auth json after login', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
204
|
-
{
|
|
205
|
-
label: '9 GET guest html',
|
|
206
|
-
method: 'GET',
|
|
207
|
-
uri: '/__probe/guest',
|
|
208
|
-
jar: true,
|
|
209
|
-
headers: { Accept: 'text/html' },
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
label: '10 POST logout',
|
|
213
|
-
method: 'POST',
|
|
214
|
-
uri: '/__probe/logout',
|
|
215
|
-
jar: true,
|
|
216
|
-
form: { _token: '@token' },
|
|
217
|
-
},
|
|
218
|
-
{ label: '11 GET auth json after logout', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
219
|
-
];
|
|
220
|
-
|
|
221
|
-
function runOracle() {
|
|
222
|
-
requireApp();
|
|
223
|
-
const steps = STEPS.map((s) => ({
|
|
224
|
-
label: s.label,
|
|
225
|
-
method: s.method,
|
|
226
|
-
uri: s.uri,
|
|
227
|
-
jar: Boolean(s.jar),
|
|
228
|
-
...(s.headers ? { headers: s.headers } : {}),
|
|
229
|
-
...(s.form ? { form: s.form } : {}),
|
|
230
|
-
}));
|
|
231
|
-
const res = spawnSync(PHP, [oraclePath], {
|
|
232
|
-
input: JSON.stringify({
|
|
233
|
-
app_root: APP_ROOT,
|
|
234
|
-
freeze_ms: FREEZE_MS,
|
|
235
|
-
user: { id: 1, name: 'Test User', email: 'u@example.com', password_hash: HASH12 },
|
|
236
|
-
steps,
|
|
237
|
-
}),
|
|
238
|
-
encoding: 'utf8',
|
|
239
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
240
|
-
});
|
|
241
|
-
if (res.error) {
|
|
242
|
-
throw new Error(
|
|
243
|
-
`PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
if (res.status !== 0) {
|
|
247
|
-
throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
|
|
248
|
-
}
|
|
249
|
-
return JSON.parse(res.stdout);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
class LWOracleUser extends Model {}
|
|
253
|
-
LWOracleUser.table = 'lw_users';
|
|
254
|
-
applyAuthenticatable(LWOracleUser);
|
|
255
|
-
|
|
256
|
-
// Minimal D1-shaped wrapper over node:sqlite.
|
|
257
|
-
function d1(sqlite) {
|
|
258
|
-
return {
|
|
259
|
-
prepare(sql) {
|
|
260
|
-
let params = [];
|
|
261
|
-
return {
|
|
262
|
-
bind(...p) {
|
|
263
|
-
params = p;
|
|
264
|
-
return this;
|
|
265
|
-
},
|
|
266
|
-
async all() {
|
|
267
|
-
const st = sqlite.prepare(sql);
|
|
268
|
-
const rows = params.length ? st.all(...params) : st.all();
|
|
269
|
-
return { results: rows.map((r) => ({ ...r })) };
|
|
270
|
-
},
|
|
271
|
-
async first() {
|
|
272
|
-
const st = sqlite.prepare(sql);
|
|
273
|
-
const row = params.length ? st.get(...params) : st.get();
|
|
274
|
-
return row ? { ...row } : null;
|
|
275
|
-
},
|
|
276
|
-
async run() {
|
|
277
|
-
const st = sqlite.prepare(sql);
|
|
278
|
-
const info = params.length ? st.run(...params) : st.run();
|
|
279
|
-
return {
|
|
280
|
-
success: true,
|
|
281
|
-
meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) },
|
|
282
|
-
};
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Schema mirrors the oracle's: Laravel's default `sessions` table + the oracle's
|
|
290
|
-
// `lw_users`, seeded with the same pinned bcrypt hash.
|
|
291
|
-
function makeDb() {
|
|
292
|
-
const sqlite = new DatabaseSync(':memory:');
|
|
293
|
-
sqlite.exec(`
|
|
294
|
-
CREATE TABLE sessions (
|
|
295
|
-
id TEXT PRIMARY KEY,
|
|
296
|
-
user_id INTEGER NULL,
|
|
297
|
-
ip_address TEXT NULL,
|
|
298
|
-
user_agent TEXT NULL,
|
|
299
|
-
payload TEXT NOT NULL,
|
|
300
|
-
last_activity INTEGER NOT NULL
|
|
301
|
-
);
|
|
302
|
-
CREATE TABLE lw_users (
|
|
303
|
-
id INTEGER PRIMARY KEY,
|
|
304
|
-
name TEXT NULL,
|
|
305
|
-
email TEXT UNIQUE,
|
|
306
|
-
email_verified_at TEXT NULL,
|
|
307
|
-
password TEXT NOT NULL,
|
|
308
|
-
remember_token TEXT NULL,
|
|
309
|
-
created_at TEXT NULL,
|
|
310
|
-
updated_at TEXT NULL
|
|
311
|
-
);
|
|
312
|
-
`);
|
|
313
|
-
sqlite
|
|
314
|
-
.prepare(
|
|
315
|
-
`INSERT INTO lw_users
|
|
316
|
-
(id, name, email, email_verified_at, password, remember_token, created_at, updated_at)
|
|
317
|
-
VALUES (1, 'Test User', 'u@example.com', NULL, ?, NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00')`
|
|
318
|
-
)
|
|
319
|
-
.run(HASH12);
|
|
320
|
-
return { sqlite, db: d1(sqlite) };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Synthetic route table. `routeKey` = `methods[0] + ' ' + uri`, so GET+HEAD
|
|
324
|
-
// routes key on the GET handler (exactly what a Laravel `Route::get()` produces).
|
|
325
|
-
const ROUTES = [
|
|
326
|
-
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web'] },
|
|
327
|
-
{ uri: '/__probe/state', methods: ['POST'], middleware: ['web'] },
|
|
328
|
-
{ uri: '/__probe/auth', methods: ['GET', 'HEAD'], middleware: ['web', 'auth'] },
|
|
329
|
-
{ uri: '/__probe/basic', methods: ['GET', 'HEAD'], middleware: ['web', 'auth.basic'] },
|
|
330
|
-
{ uri: '/__probe/guest', methods: ['GET', 'HEAD'], middleware: ['web', 'guest'] },
|
|
331
|
-
{ uri: '/__probe/login', methods: ['POST'], middleware: ['web'] },
|
|
332
|
-
{ uri: '/__probe/logout', methods: ['POST'], middleware: ['web'] },
|
|
333
|
-
{ uri: '/login', name: 'login', methods: ['GET', 'HEAD'], middleware: [] },
|
|
334
|
-
// The oracle app defines a named `dashboard` route, and
|
|
335
|
-
// RedirectIfAuthenticated::defaultRedirectUri() probes for it first — so the
|
|
336
|
-
// synthetic table has to mirror that name or `guest` redirects to `/`.
|
|
337
|
-
{ uri: '/dashboard', name: 'dashboard', methods: ['GET', 'HEAD'], middleware: [] },
|
|
338
|
-
];
|
|
339
|
-
|
|
340
|
-
function json(data, status = 200) {
|
|
341
|
-
return { status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(data) };
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function guardId(ctx) {
|
|
345
|
-
const user = await ctx.auth.guard().user();
|
|
346
|
-
return user ? user.getAuthIdentifier() : null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function stateBody(ctx) {
|
|
350
|
-
return {
|
|
351
|
-
ok: true,
|
|
352
|
-
token: ctx.session.token(),
|
|
353
|
-
keys: Object.keys(ctx.session.all()),
|
|
354
|
-
user: null, // filled below (async)
|
|
355
|
-
path: ctx.request.path(),
|
|
356
|
-
previous: ctx.session.previousUrl(),
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function stateHandler(ctx) {
|
|
361
|
-
const body = stateBody(ctx);
|
|
362
|
-
body.user = await guardId(ctx);
|
|
363
|
-
return json(body);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Mirrors the oracle's probe bodies — every one carries the *current* session
|
|
367
|
-
// token so `@token` resolves from the immediately preceding response (login and
|
|
368
|
-
// logout regenerate the CSRF token).
|
|
369
|
-
const handlers = {
|
|
370
|
-
'GET /__probe/state': stateHandler,
|
|
371
|
-
'POST /__probe/state': stateHandler,
|
|
372
|
-
'GET /__probe/auth': async (ctx) =>
|
|
373
|
-
json({ user: await guardId(ctx), token: ctx.session.token() }),
|
|
374
|
-
'GET /__probe/basic': async (ctx) =>
|
|
375
|
-
json({ user: await guardId(ctx), token: ctx.session.token() }),
|
|
376
|
-
'GET /__probe/guest': async (ctx) =>
|
|
377
|
-
json({ guest: true, token: ctx.session.token() }),
|
|
378
|
-
'POST /__probe/login': async (ctx) => {
|
|
379
|
-
const ok = await ctx.auth
|
|
380
|
-
.guard()
|
|
381
|
-
.attempt({ email: ctx.request.input('email'), password: ctx.request.input('password') });
|
|
382
|
-
return json({ ok, user: await guardId(ctx), token: ctx.session.token() });
|
|
383
|
-
},
|
|
384
|
-
'POST /__probe/logout': async (ctx) => {
|
|
385
|
-
await ctx.auth.guard().logout();
|
|
386
|
-
return json({ ok: true, user: await guardId(ctx), token: ctx.session.token() });
|
|
387
|
-
},
|
|
388
|
-
'GET /login': () => ({
|
|
389
|
-
status: 200,
|
|
390
|
-
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
391
|
-
body: 'login',
|
|
392
|
-
}),
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
function serializeJar(jar) {
|
|
396
|
-
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// --------------------------------------------------------------- comparisons
|
|
400
|
-
|
|
401
|
-
function normHeaders(headers) {
|
|
402
|
-
const out = {};
|
|
403
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
404
|
-
const key = String(name).toLowerCase();
|
|
405
|
-
if (key === 'date' || key === 'set-cookie') continue;
|
|
406
|
-
out[key] = Array.isArray(value) ? String(value[0]) : String(value);
|
|
407
|
-
}
|
|
408
|
-
return out;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Normalise the random session token out of a probe JSON body.
|
|
412
|
-
function normJsonBody(parsed) {
|
|
413
|
-
if (parsed === null || typeof parsed !== 'object') return parsed;
|
|
414
|
-
const out = { ...parsed };
|
|
415
|
-
if (typeof out.token === 'string') out.token = TOKEN;
|
|
416
|
-
return out;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function parseSetCookie(raw) {
|
|
420
|
-
const parts = String(raw).split('; ');
|
|
421
|
-
const first = parts[0];
|
|
422
|
-
const eq = first.indexOf('=');
|
|
423
|
-
const name = decodeURIComponent(first.slice(0, eq));
|
|
424
|
-
const value = decodeURIComponent(first.slice(eq + 1));
|
|
425
|
-
// Keep attribute order (parity), normalise only the Max-Age value.
|
|
426
|
-
const attrs = parts.slice(1).map((a) => (a.toLowerCase().startsWith('max-age=') ? 'max-age=*' : a));
|
|
427
|
-
return { name, value, attrs };
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function normCookies(list) {
|
|
431
|
-
return list.map(parseSetCookie).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function decodePayload(b64) {
|
|
435
|
-
return Buffer.from(String(b64), 'base64').toString('utf8');
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Normalise the payload bytes: only the CSRF token attribute is random on both
|
|
439
|
-
// sides (the session id lives in the row `id` column, never inside the payload).
|
|
440
|
-
// Everything else — attribute order, `/`-escaping, flash shape — must be
|
|
441
|
-
// byte-identical.
|
|
442
|
-
function normPayloadText(text, token) {
|
|
443
|
-
if (!token) return text;
|
|
444
|
-
return text.split(token).join(TOKEN);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function normRow(row, token) {
|
|
448
|
-
const payload = decodePayload(row.payload);
|
|
449
|
-
let keys = null;
|
|
450
|
-
try {
|
|
451
|
-
keys = Object.keys(JSON.parse(payload)).sort();
|
|
452
|
-
} catch {
|
|
453
|
-
keys = null;
|
|
454
|
-
}
|
|
455
|
-
return {
|
|
456
|
-
id_length: String(row.id).length,
|
|
457
|
-
user_id: row.user_id === null || row.user_id === undefined ? null : Number(row.user_id),
|
|
458
|
-
ip_address: row.ip_address,
|
|
459
|
-
user_agent: row.user_agent,
|
|
460
|
-
last_activity: Number(row.last_activity),
|
|
461
|
-
keys,
|
|
462
|
-
payload: normPayloadText(payload, token),
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ------------------------------------------------------------------ JS replay
|
|
467
|
-
|
|
468
|
-
async function runJs(config) {
|
|
469
|
-
const { sqlite, db } = makeDb();
|
|
470
|
-
const table = normalizeTable(ROUTES);
|
|
471
|
-
const models = { LWOracleUser };
|
|
472
|
-
const jar = new Map();
|
|
473
|
-
let lastToken = null;
|
|
474
|
-
const out = [];
|
|
475
|
-
|
|
476
|
-
for (const step of STEPS) {
|
|
477
|
-
const headers = { host: 'localhost', 'user-agent': USER_AGENT, accept: 'application/json' };
|
|
478
|
-
if (step.headers?.Accept) headers.accept = step.headers.Accept;
|
|
479
|
-
if (jar.size) headers.cookie = serializeJar(jar);
|
|
480
|
-
|
|
481
|
-
let body = '';
|
|
482
|
-
if (step.form) {
|
|
483
|
-
const form = {};
|
|
484
|
-
for (const [k, v] of Object.entries(step.form)) form[k] = v === '@token' ? lastToken : v;
|
|
485
|
-
body = new URLSearchParams(form).toString();
|
|
486
|
-
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const res = await handleHttp(
|
|
490
|
-
{ method: step.method, url: BASE + step.uri, headers, body },
|
|
491
|
-
{
|
|
492
|
-
table,
|
|
493
|
-
handlers,
|
|
494
|
-
config,
|
|
495
|
-
env: { DB: db },
|
|
496
|
-
db,
|
|
497
|
-
models,
|
|
498
|
-
clock: () => FREEZE_S,
|
|
499
|
-
randomString: (n) => 'A'.repeat(n),
|
|
500
|
-
strRandom: (n) => 'A'.repeat(n),
|
|
501
|
-
clientIp: CLIENT_IP,
|
|
502
|
-
}
|
|
503
|
-
);
|
|
504
|
-
if (!res) throw new Error(`compiled rung returned null for ${step.label} (no handler matched)`);
|
|
505
|
-
|
|
506
|
-
for (const sc of res.setCookie) {
|
|
507
|
-
const c = parseSetCookie(sc);
|
|
508
|
-
const value = c.value;
|
|
509
|
-
if (value === '' && /expires=/i.test(sc) && /max-age=0/i.test(sc)) jar.delete(c.name);
|
|
510
|
-
else jar.set(c.name, value);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
let parsed = null;
|
|
514
|
-
try {
|
|
515
|
-
parsed = JSON.parse(res.body);
|
|
516
|
-
} catch {
|
|
517
|
-
parsed = null;
|
|
518
|
-
}
|
|
519
|
-
if (parsed && typeof parsed.token === 'string') lastToken = parsed.token;
|
|
520
|
-
|
|
521
|
-
const rows = sqlite
|
|
522
|
-
.prepare('SELECT * FROM sessions ORDER BY id')
|
|
523
|
-
.all()
|
|
524
|
-
.map((r) => ({ ...r }));
|
|
525
|
-
|
|
526
|
-
out.push({
|
|
527
|
-
label: step.label,
|
|
528
|
-
status: res.status,
|
|
529
|
-
headers: res.headers,
|
|
530
|
-
setCookie: res.setCookie,
|
|
531
|
-
body: res.body,
|
|
532
|
-
parsed,
|
|
533
|
-
rows,
|
|
534
|
-
jar: new Map(jar),
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
return out;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function normPhpStep(step) {
|
|
541
|
-
const rawRows = (step.session_rows || []).map((r) => ({
|
|
542
|
-
id: r.id,
|
|
543
|
-
user_id: r.user_id,
|
|
544
|
-
ip_address: r.ip_address,
|
|
545
|
-
user_agent: r.user_agent,
|
|
546
|
-
payload: r.payload,
|
|
547
|
-
last_activity: r.last_activity,
|
|
548
|
-
}));
|
|
549
|
-
return {
|
|
550
|
-
label: step.label,
|
|
551
|
-
status: step.status,
|
|
552
|
-
headers: step.headers || {},
|
|
553
|
-
setCookie: step.set_cookie || [],
|
|
554
|
-
body: String(step.body ?? ''),
|
|
555
|
-
parsed: (() => {
|
|
556
|
-
try {
|
|
557
|
-
return JSON.parse(String(step.body ?? ''));
|
|
558
|
-
} catch {
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
})(),
|
|
562
|
-
rows: rawRows,
|
|
563
|
-
sessionPlain: step.session_plain ?? null,
|
|
564
|
-
xsrfPlain: step.xsrf_plain ?? null,
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
test('compiled pipeline matches the PHP 8.3 web pipeline, 11 steps', { skip: noSqlite || noApp }, async () => {
|
|
569
|
-
const oracle = runOracle();
|
|
570
|
-
const config = oracle.config;
|
|
571
|
-
assert.equal(config['session.driver'], 'database', 'oracle must run the database session driver');
|
|
572
|
-
assert.equal(config['session.serialization'], 'json', 'oracle must use json session serialization');
|
|
573
|
-
|
|
574
|
-
const js = await runJs(config);
|
|
575
|
-
assert.equal(js.length, STEPS.length);
|
|
576
|
-
const encryptor = createCookieEncryptor({
|
|
577
|
-
keyBase64: String(config['app.key'] ?? '').replace(/^base64:/, ''),
|
|
578
|
-
except: config['cookies.except'] ?? [],
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
for (let i = 0; i < STEPS.length; i += 1) {
|
|
582
|
-
const mine = js[i];
|
|
583
|
-
const php = normPhpStep(oracle.steps[i]);
|
|
584
|
-
const where = `step ${php.label}`;
|
|
585
|
-
|
|
586
|
-
// --- status ---------------------------------------------------------
|
|
587
|
-
assert.equal(mine.status, php.status, `${where}: status`);
|
|
588
|
-
|
|
589
|
-
// --- headers (minus date/set-cookie) --------------------------------
|
|
590
|
-
assert.deepEqual(normHeaders(mine.headers), normHeaders(php.headers), `${where}: headers`);
|
|
591
|
-
assert.equal(
|
|
592
|
-
String(normHeaders(mine.headers)['cache-control']),
|
|
593
|
-
'no-cache, private',
|
|
594
|
-
`${where}: cache-control must be the Symfony default`
|
|
595
|
-
);
|
|
596
|
-
|
|
597
|
-
// --- Set-Cookie -----------------------------------------------------
|
|
598
|
-
assert.deepEqual(
|
|
599
|
-
normCookies(mine.setCookie).map((c) => ({ name: c.name, attrs: c.attrs })),
|
|
600
|
-
normCookies(php.setCookie).map((c) => ({ name: c.name, attrs: c.attrs })),
|
|
601
|
-
`${where}: Set-Cookie name+attributes (order preserved)`
|
|
602
|
-
);
|
|
603
|
-
for (const c of normCookies(mine.setCookie)) {
|
|
604
|
-
assert.ok(c.value.length > 0, `${where}: cookie ${c.name} must carry a value`);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// --- body -----------------------------------------------------------
|
|
608
|
-
const ct = String(normHeaders(php.headers)['content-type'] || '');
|
|
609
|
-
if (ct.startsWith('application/json')) {
|
|
610
|
-
if (php.status === 419) {
|
|
611
|
-
// Laravel's debug 419 body embeds absolute file/line/trace paths.
|
|
612
|
-
assert.equal(mine.parsed?.message, php.parsed?.message, `${where}: 419 message`);
|
|
613
|
-
} else {
|
|
614
|
-
assert.deepEqual(normJsonBody(mine.parsed), normJsonBody(php.parsed), `${where}: JSON body`);
|
|
615
|
-
}
|
|
616
|
-
} else {
|
|
617
|
-
assert.equal(mine.body, php.body, `${where}: body`);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// --- session id / CSRF token (random on both sides) -----------------
|
|
621
|
-
const sid = php.sessionPlain;
|
|
622
|
-
const xsrf = php.xsrfPlain;
|
|
623
|
-
assert.ok(sid, `${where}: oracle must report session_plain`);
|
|
624
|
-
assert.equal(String(sid).length, 40, `${where}: oracle session id length`);
|
|
625
|
-
assert.ok(xsrf, `${where}: oracle must report xsrf_plain`);
|
|
626
|
-
assert.equal(String(xsrf).length, 40, `${where}: oracle CSRF token length`);
|
|
627
|
-
|
|
628
|
-
const jsSid = await jarPlain(encryptor, mine.jar, config['session.cookie']);
|
|
629
|
-
const jsXsrf = await jarPlain(encryptor, mine.jar, 'XSRF-TOKEN');
|
|
630
|
-
assert.equal(String(jsSid).length, 40, `${where}: compiled session id length`);
|
|
631
|
-
assert.equal(String(jsXsrf).length, 40, `${where}: compiled CSRF token length`);
|
|
632
|
-
|
|
633
|
-
// --- session row + payload bytes ------------------------------------
|
|
634
|
-
// Each side is normalised with its *own* two random values, so the payload
|
|
635
|
-
// bytes must then be byte-identical (attribute order, `/`-escaping, flash
|
|
636
|
-
// shape, login/remember keys, everything).
|
|
637
|
-
const mineRows = mine.rows.map((r) => normRow(r, jsXsrf));
|
|
638
|
-
const phpRows = php.rows.map((r) => normRow(r, xsrf));
|
|
639
|
-
assert.equal(mineRows.length, phpRows.length, `${where}: session row count`);
|
|
640
|
-
assert.deepEqual(mineRows, phpRows, `${where}: session rows (payload bytes + columns)`);
|
|
641
|
-
|
|
642
|
-
// --- cookie jar -----------------------------------------------------
|
|
643
|
-
const phpJar = Object.keys(oracle.steps[i].jar_after || {}).sort();
|
|
644
|
-
const jsJar = [...mine.jar.keys()].sort();
|
|
645
|
-
assert.deepEqual(jsJar, phpJar, `${where}: cookie jar names`);
|
|
646
|
-
}
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
// Decrypt a request-cookie value out of a jar with the EncryptCookies scheme.
|
|
650
|
-
async function jarPlain(encryptor, jar, name) {
|
|
651
|
-
const raw = jar.get(name);
|
|
652
|
-
if (raw === undefined) return null;
|
|
653
|
-
const out = await encryptor.decryptRequestCookies({ [name]: raw });
|
|
654
|
-
return out[name];
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ---------------------------------------------------------------------------
|
|
658
|
-
// 3. fail-loud guards
|
|
659
|
-
// ---------------------------------------------------------------------------
|
|
660
|
-
|
|
661
|
-
async function callPipeline(mutate) {
|
|
662
|
-
const { db } = makeDb();
|
|
663
|
-
const config = baseConfig();
|
|
664
|
-
const table = normalizeTable(ROUTES);
|
|
665
|
-
const options = {
|
|
666
|
-
table,
|
|
667
|
-
handlers,
|
|
668
|
-
config,
|
|
669
|
-
env: { DB: db },
|
|
670
|
-
db,
|
|
671
|
-
models: { LWOracleUser },
|
|
672
|
-
clock: () => FREEZE_S,
|
|
673
|
-
randomString: (n) => 'A'.repeat(n),
|
|
674
|
-
strRandom: (n) => 'A'.repeat(n),
|
|
675
|
-
clientIp: CLIENT_IP,
|
|
676
|
-
};
|
|
677
|
-
if (mutate) mutate(options);
|
|
678
|
-
return handleHttp(
|
|
679
|
-
{
|
|
680
|
-
method: 'GET',
|
|
681
|
-
url: BASE + '/__probe/state',
|
|
682
|
-
headers: { host: 'localhost', accept: 'application/json' },
|
|
683
|
-
body: '',
|
|
684
|
-
},
|
|
685
|
-
options
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function baseConfig() {
|
|
690
|
-
return {
|
|
691
|
-
'app.key': 'base64:3xd/I1bUWfA8Rugdso1ILBcgFHCBKkPtC8GLvAsSYHY=',
|
|
692
|
-
'app.url': 'http://localhost',
|
|
693
|
-
'session.driver': 'database',
|
|
694
|
-
'session.cookie': 'laravel-session',
|
|
695
|
-
'session.lifetime': 120,
|
|
696
|
-
'session.path': '/',
|
|
697
|
-
'session.domain': null,
|
|
698
|
-
'session.secure': null,
|
|
699
|
-
'session.http_only': true,
|
|
700
|
-
'session.same_site': 'lax',
|
|
701
|
-
'session.serialization': 'json',
|
|
702
|
-
'session.table': 'sessions',
|
|
703
|
-
'session.encrypt': false,
|
|
704
|
-
'cookies.except': [],
|
|
705
|
-
'csrf.except': [],
|
|
706
|
-
'auth.defaults.guard': 'web',
|
|
707
|
-
'auth.guards.web': { driver: 'session', provider: 'users' },
|
|
708
|
-
'auth.providers.users': { driver: 'eloquent', model: 'LWOracleUser' },
|
|
709
|
-
'auth.hash_key': 'base64:3xd/I1bUWfA8Rugdso1ILBcgFHCBKkPtC8GLvAsSYHY=',
|
|
710
|
-
'auth.timebox_duration': 200000,
|
|
711
|
-
'auth.rehash_on_login': true,
|
|
712
|
-
'hashing.driver': 'bcrypt',
|
|
713
|
-
'hashing.bcrypt.rounds': '12',
|
|
714
|
-
'hashing.bcrypt.verify': true,
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
test('fail loud: unknown route middleware is rejected', async () => {
|
|
719
|
-
await assert.rejects(
|
|
720
|
-
callPipeline((o) => {
|
|
721
|
-
o.table = normalizeTable([
|
|
722
|
-
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'can:update,post'] },
|
|
723
|
-
]);
|
|
724
|
-
}),
|
|
725
|
-
/not supported by the compiled rung/
|
|
726
|
-
);
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
test('fail loud: throttle middleware without a cache store', async () => {
|
|
730
|
-
await assert.rejects(
|
|
731
|
-
callPipeline((o) => {
|
|
732
|
-
o.table = normalizeTable([
|
|
733
|
-
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'throttle:60,1'] },
|
|
734
|
-
]);
|
|
735
|
-
}),
|
|
736
|
-
/cache store/
|
|
737
|
-
);
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
// ---------------------------------------------------------------------------
|
|
741
|
-
// The method-not-allowed path (Laravel's AbstractRouteCollection):
|
|
742
|
-
// - an OPTIONS request gets the automatic OPTIONS response (200, an empty
|
|
743
|
-
// body, the Allow header with the verbs the URI accepts);
|
|
744
|
-
// - any other method raises MethodNotAllowedHttpException: 405 when the app
|
|
745
|
-
// has an errors/405 view, else the framework's error renderer answers 500.
|
|
746
|
-
// ---------------------------------------------------------------------------
|
|
747
|
-
|
|
748
|
-
async function callMethod(method, uri, mutate) {
|
|
749
|
-
const { db } = makeDb();
|
|
750
|
-
const config = baseConfig();
|
|
751
|
-
const options = {
|
|
752
|
-
table: normalizeTable(ROUTES),
|
|
753
|
-
handlers,
|
|
754
|
-
config,
|
|
755
|
-
env: { DB: db },
|
|
756
|
-
db,
|
|
757
|
-
models: { LWOracleUser },
|
|
758
|
-
clock: () => FREEZE_S,
|
|
759
|
-
randomString: (n) => 'A'.repeat(n),
|
|
760
|
-
strRandom: (n) => 'A'.repeat(n),
|
|
761
|
-
clientIp: CLIENT_IP,
|
|
762
|
-
};
|
|
763
|
-
if (mutate) mutate(options);
|
|
764
|
-
return handleHttp(
|
|
765
|
-
{ method, url: BASE + uri, headers: { host: 'localhost' }, body: '' },
|
|
766
|
-
options
|
|
767
|
-
);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
test('router: an OPTIONS request gets the automatic OPTIONS response', async () => {
|
|
771
|
-
const res = await callMethod('OPTIONS', '/__probe/state', (o) => {
|
|
772
|
-
o.table = normalizeTable([
|
|
773
|
-
{ uri: '/__probe/state', methods: ['GET', 'HEAD'] },
|
|
774
|
-
{ uri: '/__probe/state', methods: ['POST'] },
|
|
775
|
-
]);
|
|
776
|
-
});
|
|
777
|
-
assert.equal(res.status, 200);
|
|
778
|
-
assert.equal(res.body, '');
|
|
779
|
-
// Laravel: implode(',', $methods) - the verbs the URI accepts, no spaces.
|
|
780
|
-
assert.equal(res.headers.allow, 'GET,HEAD,POST');
|
|
781
|
-
assert.equal(res.headers['content-type'], 'text/html; charset=utf-8');
|
|
782
|
-
assert.equal(res.headers['cache-control'], 'no-cache, private');
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
test('router: a method that is not allowed answers like Laravel', async () => {
|
|
786
|
-
const res = await callMethod('DELETE', '/__probe/state', (o) => {
|
|
787
|
-
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
788
|
-
});
|
|
789
|
-
// No errors/405 view in the app: the framework's error renderer answers 500.
|
|
790
|
-
assert.equal(res.status, 500);
|
|
791
|
-
assert.equal(res.headers.allow, undefined);
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
test('router: a method that is not allowed answers 405 with an errors/405 view', async () => {
|
|
795
|
-
const res = await callMethod('DELETE', '/__probe/state', (o) => {
|
|
796
|
-
o.config['errors.405_view'] = true;
|
|
797
|
-
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
798
|
-
});
|
|
799
|
-
assert.equal(res.status, 405);
|
|
800
|
-
// Symfony's MethodNotAllowedHttpException: implode(', ', $allowedMethods).
|
|
801
|
-
assert.equal(res.headers.allow, 'GET, HEAD');
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
test('router: a URI that matches no route is left to the platform', async () => {
|
|
805
|
-
const res = await callMethod('GET', '/__probe/nothing', (o) => {
|
|
806
|
-
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
807
|
-
});
|
|
808
|
-
assert.equal(res, null);
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
test('throttle over the D1 cache store: first N allowed, then 429', async (t) => {
|
|
812
|
-
if (noSqlite) return t.skip(noSqlite);
|
|
813
|
-
const { sqlite, db } = makeDb();
|
|
814
|
-
sqlite.exec(`
|
|
815
|
-
CREATE TABLE cache ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL, "expiration" INTEGER NOT NULL);
|
|
816
|
-
CREATE TABLE cache_locks ("key" TEXT PRIMARY KEY, "owner" TEXT NOT NULL, "expiration" INTEGER NOT NULL);
|
|
817
|
-
`);
|
|
818
|
-
const cache = createDatabaseCache({
|
|
819
|
-
db,
|
|
820
|
-
config: { 'cache.table': 'cache', 'cache.prefix': 'laravel-cache-', 'cache.lock_table': 'cache_locks' },
|
|
821
|
-
});
|
|
822
|
-
const table = normalizeTable([
|
|
823
|
-
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'throttle:2,1'] },
|
|
824
|
-
]);
|
|
825
|
-
const call = () =>
|
|
826
|
-
handleHttp(
|
|
827
|
-
{
|
|
828
|
-
method: 'GET',
|
|
829
|
-
url: BASE + '/__probe/state',
|
|
830
|
-
headers: { host: 'localhost', accept: 'application/json' },
|
|
831
|
-
body: '',
|
|
832
|
-
},
|
|
833
|
-
{
|
|
834
|
-
table,
|
|
835
|
-
handlers: { 'GET /__probe/state': handlers['GET /__probe/state'] },
|
|
836
|
-
config: baseConfig(),
|
|
837
|
-
env: { DB: db },
|
|
838
|
-
db,
|
|
839
|
-
cache,
|
|
840
|
-
models: { LWOracleUser },
|
|
841
|
-
clock: () => FREEZE_S,
|
|
842
|
-
randomString: (n) => 'A'.repeat(n),
|
|
843
|
-
strRandom: (n) => 'A'.repeat(n),
|
|
844
|
-
clientIp: CLIENT_IP,
|
|
845
|
-
}
|
|
846
|
-
);
|
|
847
|
-
|
|
848
|
-
const r1 = await call();
|
|
849
|
-
const r2 = await call();
|
|
850
|
-
const r3 = await call();
|
|
851
|
-
assert.equal(r1.status, 200, 'hit #1 within throttle:2,1');
|
|
852
|
-
assert.equal(r2.status, 200, 'hit #2 within throttle:2,1');
|
|
853
|
-
assert.equal(r3.status, 429, 'hit #3 exceeds throttle:2,1');
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
// The `auth.basic` middleware (Illuminate\Auth\Middleware\AuthenticateWithBasicAuth):
|
|
857
|
-
// the Basic credentials are verified and the user is logged in; a failure is a 401
|
|
858
|
-
// with Symfony's `WWW-Authenticate: Basic realm="Basic"` challenge.
|
|
859
|
-
function basicHeader(user, password) {
|
|
860
|
-
return 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
test('auth.basic: a missing Basic header is a 401 with the challenge', async () => {
|
|
864
|
-
const { db } = makeDb();
|
|
865
|
-
const res = await handleHttp(
|
|
866
|
-
{ method: 'GET', url: BASE + '/__probe/basic', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
867
|
-
{
|
|
868
|
-
table: normalizeTable(ROUTES),
|
|
869
|
-
handlers,
|
|
870
|
-
config: baseConfig(),
|
|
871
|
-
env: { DB: db },
|
|
872
|
-
db,
|
|
873
|
-
models: { LWOracleUser },
|
|
874
|
-
clock: () => FREEZE_S,
|
|
875
|
-
clientIp: CLIENT_IP,
|
|
876
|
-
}
|
|
877
|
-
);
|
|
878
|
-
assert.equal(res.status, 401);
|
|
879
|
-
// Symfony's UnauthorizedHttpException('Basic') — the challenge is the
|
|
880
|
-
// exception's argument, so the header is exactly `Basic`.
|
|
881
|
-
assert.equal(res.headers['www-authenticate'], 'Basic');
|
|
882
|
-
});
|
|
883
|
-
|
|
884
|
-
test('auth.basic: wrong credentials are a 401, valid credentials log the user in', async () => {
|
|
885
|
-
const { db } = makeDb();
|
|
886
|
-
const call = (authorization) =>
|
|
887
|
-
handleHttp(
|
|
888
|
-
{
|
|
889
|
-
method: 'GET',
|
|
890
|
-
url: BASE + '/__probe/basic',
|
|
891
|
-
headers: { host: 'localhost', accept: 'application/json', authorization },
|
|
892
|
-
body: '',
|
|
893
|
-
},
|
|
894
|
-
{
|
|
895
|
-
table: normalizeTable(ROUTES),
|
|
896
|
-
handlers,
|
|
897
|
-
config: baseConfig(),
|
|
898
|
-
env: { DB: db },
|
|
899
|
-
db,
|
|
900
|
-
models: { LWOracleUser },
|
|
901
|
-
clock: () => FREEZE_S,
|
|
902
|
-
clientIp: CLIENT_IP,
|
|
903
|
-
}
|
|
904
|
-
);
|
|
905
|
-
|
|
906
|
-
const bad = await call(basicHeader('u@example.com', 'wrong'));
|
|
907
|
-
assert.equal(bad.status, 401);
|
|
908
|
-
assert.equal(bad.headers['www-authenticate'], 'Basic');
|
|
909
|
-
|
|
910
|
-
const ok = await call(basicHeader('u@example.com', 'secret'));
|
|
911
|
-
assert.equal(ok.status, 200);
|
|
912
|
-
assert.equal(JSON.parse(ok.body).user, 1);
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
test('the service adapters (storage/mail/queue) reach the handler context', async () => {
|
|
916
|
-
const { db } = makeDb();
|
|
917
|
-
const sent = [];
|
|
918
|
-
const storage = {
|
|
919
|
-
async put(k, v) { sent.push(['put', k, v]); return k; },
|
|
920
|
-
async getText(k) { return sent.find((s) => s[1] === k) ? 'body' : null; },
|
|
921
|
-
async exists(k) { return sent.some((s) => s[1] === k); },
|
|
922
|
-
async delete(k) { sent.push(['delete', k]); },
|
|
923
|
-
};
|
|
924
|
-
const mail = { async send(m) { sent.push(['mail', m.to]); return { id: 'x' }; } };
|
|
925
|
-
const queue = { async push(q, p) { sent.push(['queue', q, p]); return { id: 'q' }; } };
|
|
926
|
-
const table = normalizeTable([{ uri: '/__probe/services', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
927
|
-
const res = await handleHttp(
|
|
928
|
-
{ method: 'GET', url: BASE + '/__probe/services', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
929
|
-
{
|
|
930
|
-
table,
|
|
931
|
-
handlers: {
|
|
932
|
-
'GET /__probe/services': async (ctx) => {
|
|
933
|
-
await ctx.storage.put('a.txt', 'x');
|
|
934
|
-
const v = await ctx.storage.getText('a.txt');
|
|
935
|
-
await ctx.mail.send({ to: 'a@b.co' });
|
|
936
|
-
await ctx.queue.push('default', { id: 1 });
|
|
937
|
-
return json({ v, sent: sent.length });
|
|
938
|
-
},
|
|
939
|
-
},
|
|
940
|
-
config: baseConfig(),
|
|
941
|
-
env: { DB: db },
|
|
942
|
-
db,
|
|
943
|
-
models: { LWOracleUser },
|
|
944
|
-
storage,
|
|
945
|
-
mail,
|
|
946
|
-
queue,
|
|
947
|
-
clock: () => FREEZE_S,
|
|
948
|
-
clientIp: CLIENT_IP,
|
|
949
|
-
}
|
|
950
|
-
);
|
|
951
|
-
assert.equal(res.status, 200);
|
|
952
|
-
assert.equal(JSON.parse(res.body).v, 'body');
|
|
953
|
-
assert.deepEqual(
|
|
954
|
-
sent.map((s) => s[0]),
|
|
955
|
-
['put', 'mail', 'queue']
|
|
956
|
-
);
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
test('fail loud: session.encrypt=true and unknown drivers', async () => {
|
|
960
|
-
await assert.rejects(
|
|
961
|
-
callPipeline((o) => {
|
|
962
|
-
o.config['session.encrypt'] = true;
|
|
963
|
-
}),
|
|
964
|
-
/SESSION_ENCRYPT=true/
|
|
965
|
-
);
|
|
966
|
-
|
|
967
|
-
await assert.rejects(
|
|
968
|
-
callPipeline((o) => {
|
|
969
|
-
o.config['session.driver'] = 'redis';
|
|
970
|
-
}),
|
|
971
|
-
/session driver 'redis'/
|
|
972
|
-
);
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
test('routes with no compiled handler return null (assets served by the worker)', async () => {
|
|
976
|
-
const res = await callPipeline((o) => {
|
|
977
|
-
o.table = normalizeTable([{ uri: '/storage/asset.png', methods: ['GET', 'HEAD'] }]);
|
|
978
|
-
o.handlers = {};
|
|
979
|
-
});
|
|
980
|
-
assert.equal(res, null);
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
984
|
-
test('status-only: missing CSRF token returns 419', async () => {
|
|
985
|
-
const { db } = makeDb();
|
|
986
|
-
const config = baseConfig();
|
|
987
|
-
const table = normalizeTable(ROUTES);
|
|
988
|
-
const res = await handleHttp(
|
|
989
|
-
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
990
|
-
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
991
|
-
);
|
|
992
|
-
assert.equal(res.status, 419);
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
996
|
-
const { HttpError } = await import('../http.mjs');
|
|
997
|
-
const { db } = makeDb();
|
|
998
|
-
const config = baseConfig();
|
|
999
|
-
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1000
|
-
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1001
|
-
const res = await handleHttp(
|
|
1002
|
-
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1003
|
-
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1004
|
-
);
|
|
1005
|
-
assert.equal(res.status, 500);
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
test('status-only: renderErrorView hook overrides 419 body', async () => {
|
|
1009
|
-
const { db } = makeDb();
|
|
1010
|
-
const config = baseConfig();
|
|
1011
|
-
const table = normalizeTable(ROUTES);
|
|
1012
|
-
const res = await handleHttp(
|
|
1013
|
-
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1014
|
-
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP,
|
|
1015
|
-
renderErrorView: (status) => (status === 419 ? '<html><body>custom-419</body></html>' : null) }
|
|
1016
|
-
);
|
|
1017
|
-
assert.equal(res.status, 419);
|
|
1018
|
-
assert.match(res.body, /custom-419/);
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
1022
|
-
test('status-only: missing CSRF token returns 419', async () => {
|
|
1023
|
-
const { db } = makeDb();
|
|
1024
|
-
const config = baseConfig();
|
|
1025
|
-
const table = normalizeTable(ROUTES);
|
|
1026
|
-
const res = await handleHttp(
|
|
1027
|
-
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1028
|
-
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1029
|
-
);
|
|
1030
|
-
assert.equal(res.status, 419);
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
1034
|
-
const { HttpError } = await import('../http.mjs');
|
|
1035
|
-
const { db } = makeDb();
|
|
1036
|
-
const config = baseConfig();
|
|
1037
|
-
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1038
|
-
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1039
|
-
const res = await handleHttp(
|
|
1040
|
-
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1041
|
-
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1042
|
-
);
|
|
1043
|
-
assert.equal(res.status, 500);
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
1047
|
-
test('status-only: missing CSRF token returns 419', async () => {
|
|
1048
|
-
const { db } = makeDb();
|
|
1049
|
-
const config = baseConfig();
|
|
1050
|
-
const table = normalizeTable(ROUTES);
|
|
1051
|
-
const res = await handleHttp(
|
|
1052
|
-
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1053
|
-
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1054
|
-
);
|
|
1055
|
-
assert.equal(res.status, 419);
|
|
1056
|
-
});
|
|
1057
|
-
|
|
1058
|
-
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
1059
|
-
const { HttpError } = await import('../http.mjs');
|
|
1060
|
-
const { db } = makeDb();
|
|
1061
|
-
const config = baseConfig();
|
|
1062
|
-
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1063
|
-
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1064
|
-
const res = await handleHttp(
|
|
1065
|
-
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1066
|
-
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1067
|
-
);
|
|
1068
|
-
assert.equal(res.status, 500);
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
test('status-only: renderErrorView hook overrides 419 body', async () => {
|
|
1072
|
-
const { db } = makeDb();
|
|
1073
|
-
const config = baseConfig();
|
|
1074
|
-
const table = normalizeTable(ROUTES);
|
|
1075
|
-
const res = await handleHttp(
|
|
1076
|
-
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1077
|
-
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP,
|
|
1078
|
-
renderErrorView: (status) => (status === 419 ? '<html><body>custom-419</body></html>' : null) }
|
|
1079
|
-
);
|
|
1080
|
-
assert.equal(res.status, 419);
|
|
1081
|
-
assert.match(res.body, /custom-419/);
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
test('request: a multipart body is parsed (the fields + the uploaded file)', () => {
|
|
1085
|
-
const boundary = '----lwBoundary';
|
|
1086
|
-
const body =
|
|
1087
|
-
`--${boundary}\r\n` +
|
|
1088
|
-
'Content-Disposition: form-data; name="title"\r\n\r\n' +
|
|
1089
|
-
'Shadow upload\r\n' +
|
|
1090
|
-
`--${boundary}\r\n` +
|
|
1091
|
-
'Content-Disposition: form-data; name="avatar"; filename="a b.txt"\r\n' +
|
|
1092
|
-
'Content-Type: text/plain\r\n\r\n' +
|
|
1093
|
-
'hello file\r\n' +
|
|
1094
|
-
`--${boundary}--\r\n`;
|
|
1095
|
-
const req = buildRequest({
|
|
1096
|
-
method: 'POST',
|
|
1097
|
-
url: 'http://localhost/upload',
|
|
1098
|
-
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
1099
|
-
body: new TextEncoder().encode(body),
|
|
1100
|
-
});
|
|
1101
|
-
assert.equal(req.input('title'), 'Shadow upload');
|
|
1102
|
-
const file = req.file('avatar');
|
|
1103
|
-
assert.equal(file.getClientOriginalName(), 'a b.txt');
|
|
1104
|
-
assert.equal(file.getClientOriginalExtension(), 'txt');
|
|
1105
|
-
assert.equal(file.getClientMimeType(), 'text/plain');
|
|
1106
|
-
assert.equal(file.getSize(), 10);
|
|
1107
|
-
assert.equal(new TextDecoder().decode(file.get()), 'hello file');
|
|
1108
|
-
assert.equal(req.hasFile('avatar'), true);
|
|
1109
|
-
assert.equal(req.hasFile('nope'), false);
|
|
1110
|
-
// allFiles(): the map of the uploaded files (Laravel's FileBag::all()).
|
|
1111
|
-
assert.equal(Object.keys(req.allFiles()).length, 1);
|
|
1112
|
-
assert.equal(req.allFiles().avatar.getClientOriginalName(), 'a b.txt');
|
|
1113
|
-
assert.equal(req.file('nope'), null);
|
|
1114
|
-
// The files are in the validated data (Laravel's all() merges them).
|
|
1115
|
-
assert.equal(req.all().avatar.getSize(), 10);
|
|
1116
|
-
// ... but the body wins on a key that is in both.
|
|
1117
|
-
assert.equal(typeof req.all().title, 'string');
|
|
1118
|
-
});
|
|
1119
|
-
|
|
1120
|
-
test('request: the method override follows Symfony (the header wins, A-Z only)', () => {
|
|
1121
|
-
// Symfony's getMethod(): the X-HTTP-Method-Override header wins over the
|
|
1122
|
-
// `_method` parameter (body/query), and an override that is not A-Z throws
|
|
1123
|
-
// a SuspiciousOperationException ('Invalid HTTP method override.') - which
|
|
1124
|
-
// Laravel does not map, so the answer is a 500.
|
|
1125
|
-
const req = buildRequest({
|
|
1126
|
-
method: 'POST',
|
|
1127
|
-
url: 'http://localhost/x',
|
|
1128
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-http-method-override': 'PUT' },
|
|
1129
|
-
body: '_method=DELETE',
|
|
1130
|
-
});
|
|
1131
|
-
assert.equal(req.methodOverride(), 'PUT', 'the header wins over the _method parameter');
|
|
1132
|
-
const req2 = buildRequest({
|
|
1133
|
-
method: 'POST',
|
|
1134
|
-
url: 'http://localhost/x',
|
|
1135
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
1136
|
-
body: '_method=DELETE',
|
|
1137
|
-
});
|
|
1138
|
-
assert.equal(req2.methodOverride(), 'DELETE', 'the _method parameter is honoured');
|
|
1139
|
-
assert.throws(
|
|
1140
|
-
() =>
|
|
1141
|
-
buildRequest({
|
|
1142
|
-
method: 'POST',
|
|
1143
|
-
url: 'http://localhost/x',
|
|
1144
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-http-method-override': 'PU T' },
|
|
1145
|
-
body: '',
|
|
1146
|
-
}),
|
|
1147
|
-
/Invalid HTTP method override/,
|
|
1148
|
-
);
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
test('request: merge() follows Laravel (the input source, replace not deep merge)', () => {
|
|
1152
|
-
// Laravel's Request::merge() (Request.php:389-401), probed:
|
|
1153
|
-
// the new input goes into the request's INPUT SOURCE (the JSON bag, the
|
|
1154
|
-
// query bag for GET/HEAD, the post bag otherwise);
|
|
1155
|
-
// every key REPLACES the value (it is NOT a deep merge: the old sub-keys
|
|
1156
|
-
// are gone) and the dot notation is honoured (Arr::set);
|
|
1157
|
-
// the other keys are kept.
|
|
1158
|
-
const req = buildRequest({
|
|
1159
|
-
method: 'POST',
|
|
1160
|
-
url: 'http://localhost/x?keep=1',
|
|
1161
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
1162
|
-
body: 'a=1&nested[x]=1&nested[y]=2',
|
|
1163
|
-
});
|
|
1164
|
-
req.merge({ a: '2', nested: { y: '9' }, b: '3', deep: { k: 'v' } });
|
|
1165
|
-
assert.equal(req.input('a'), '2', 'the key is replaced');
|
|
1166
|
-
assert.deepEqual(req.input('nested'), { y: '9' }, 'the value is replaced, not deep-merged');
|
|
1167
|
-
assert.equal(req.input('b'), '3', 'a new key is added');
|
|
1168
|
-
assert.deepEqual(req.input('deep'), { k: 'v' });
|
|
1169
|
-
// The dot notation is honoured (Arr::set): merge(['deep.k' => 'v']) sets the
|
|
1170
|
-
// NESTED value (probed against Laravel 13).
|
|
1171
|
-
req.merge({ 'deep.k2': 'w' });
|
|
1172
|
-
assert.deepEqual(req.input('deep'), { k: 'v', k2: 'w' });
|
|
1173
|
-
// The input Laravel reads is the input source UNION the query bag
|
|
1174
|
-
// (InteractsWithInput::input(): `$this->getInputSource()->all() +
|
|
1175
|
-
// $this->query->all()`) - the source's keys win.
|
|
1176
|
-
assert.equal(req.query('keep'), '1', 'the query bag is untouched');
|
|
1177
|
-
assert.equal(req.input('keep'), '1', 'the query bag is part of the input');
|
|
1178
|
-
// A GET merges into the query bag.
|
|
1179
|
-
const get = buildRequest({ method: 'GET', url: 'http://localhost/x?q=1' });
|
|
1180
|
-
get.merge({ q: '2' });
|
|
1181
|
-
assert.equal(get.input('q'), '2');
|
|
1182
|
-
});
|
|
1
|
+
// kernel/tests/http.test.mjs — compiled HTTP pipeline parity vs PHP 8.3.
|
|
2
|
+
//
|
|
3
|
+
// Two independent proofs:
|
|
4
|
+
//
|
|
5
|
+
// 1. parsePhpQuery: the compiled rung's `a[b][c]`/`a[]` parser compared
|
|
6
|
+
// against the live PHP 8.3 `parse_str` for a broad case table. Anything
|
|
7
|
+
// that diverges is either fixed or pinned as an explicit KNOWN_DIVERGENCE
|
|
8
|
+
// (never silently tolerated).
|
|
9
|
+
//
|
|
10
|
+
// 2. The 11-step pipeline scenario: tools/php-http-pipeline-oracle.php boots a
|
|
11
|
+
// real Laravel 13.37 web pipeline (EncryptCookies → AddQueuedCookiesToResponse
|
|
12
|
+
// → StartSession → PreventRequestForgery → route middleware) over a dedicated
|
|
13
|
+
// sqlite database and replays a cookie-jar session that covers a fresh
|
|
14
|
+
// session, a CSRF failure, a JSON/HTML unauthenticated redirect, a login
|
|
15
|
+
// (session id + CSRF token regeneration), a guest redirect and a logout.
|
|
16
|
+
// The compiled `handleHttp` replays the exact same steps and both sides are
|
|
17
|
+
// compared per step:
|
|
18
|
+
//
|
|
19
|
+
// - status (exact)
|
|
20
|
+
// - response headers minus `date`/`set-cookie` (exact)
|
|
21
|
+
// - Set-Cookie per cookie: name + attribute list in order (the `Max-Age`
|
|
22
|
+
// *value* is normalised: Symfony derives it from real time(), which the
|
|
23
|
+
// frozen-clock oracle cannot reproduce — the `expires` value is asserted
|
|
24
|
+
// exactly instead)
|
|
25
|
+
// - body: JSON parsed + deepEqual; redirect bodies byte-exact; the 419
|
|
26
|
+
// debug body (absolute file/line/trace) is compared status + message
|
|
27
|
+
// - session row columns, and the raw session payload *bytes* after
|
|
28
|
+
// normalising the two random values (session id, CSRF token)
|
|
29
|
+
//
|
|
30
|
+
// Every random value is one of exactly two: the session id (random 40 chars) and
|
|
31
|
+
// the CSRF token (random 40 chars). Both are fed deterministically to the
|
|
32
|
+
// compiled side and normalised out of the payload comparison on both sides.
|
|
33
|
+
import { test } from 'node:test';
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { createCookieEncryptor } from '../cookies.mjs';
|
|
40
|
+
import { handleHttp, parsePhpQuery, buildRequest } from '../http.mjs';
|
|
41
|
+
import { createDatabaseCache } from '../cache.mjs';
|
|
42
|
+
import { applyAuthenticatable } from '../auth.mjs';
|
|
43
|
+
import { Model } from '../model.mjs';
|
|
44
|
+
import { normalizeTable } from '../router.mjs';
|
|
45
|
+
import { oracleAppRoot, oracleSkip } from './oracle-app.mjs';
|
|
46
|
+
|
|
47
|
+
let DatabaseSync = null;
|
|
48
|
+
try {
|
|
49
|
+
({ DatabaseSync } = await import('node:sqlite'));
|
|
50
|
+
} catch {
|
|
51
|
+
// Older Node without node:sqlite: the pipeline section skips.
|
|
52
|
+
}
|
|
53
|
+
const noSqlite = DatabaseSync ? false : 'node:sqlite unavailable (needs Node >= 22)';
|
|
54
|
+
|
|
55
|
+
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-http-pipeline-oracle.php');
|
|
57
|
+
const PHP = process.env.PHP_BIN || 'php';
|
|
58
|
+
const APP_ROOT = oracleAppRoot();
|
|
59
|
+
// The PHP oracle parity tests need a real Laravel app (PHP 8.3 + vendor/).
|
|
60
|
+
// Without one they SKIP (the CI `test` job has no app; the `e2e` job points
|
|
61
|
+
// LW_LARAVEL_APP at the app it creates and runs them for real).
|
|
62
|
+
const noApp = APP_ROOT ? false : oracleSkip('http.test.mjs');
|
|
63
|
+
|
|
64
|
+
const FREEZE_MS = 1792336827000;
|
|
65
|
+
const FREEZE_S = Math.floor(FREEZE_MS / 1000);
|
|
66
|
+
const HASH12 = '$2y$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
|
|
67
|
+
const CLIENT_IP = '127.0.0.1';
|
|
68
|
+
const USER_AGENT = 'Symfony';
|
|
69
|
+
const BASE = 'http://localhost';
|
|
70
|
+
const TOKEN = '\u00abTOKEN\u00bb';
|
|
71
|
+
|
|
72
|
+
function requireApp() {
|
|
73
|
+
if (!APP_ROOT || !existsSync(path.join(APP_ROOT, 'vendor', 'autoload.php'))) {
|
|
74
|
+
throw new Error(oracleSkip('http.test.mjs'));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 1. parsePhpQuery vs the live PHP 8.3 parse_str
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
//
|
|
82
|
+
// MATCH_CASES must be byte-identical on both sides. KNOWN_DIVERGENCES are
|
|
83
|
+
// asserted to *stay* divergent (pinned to PHP's output) so a future change that
|
|
84
|
+
// silently widens or narrows the gap fails the suite.
|
|
85
|
+
const MATCH_CASES = [
|
|
86
|
+
'a=1&b=2',
|
|
87
|
+
'a[]=1&a[]=2',
|
|
88
|
+
'a[b]=1&a[b][c]=2',
|
|
89
|
+
'a[b][c]=2&a[b]=1',
|
|
90
|
+
'a.b=1',
|
|
91
|
+
'a b=2',
|
|
92
|
+
'a=1&a=2',
|
|
93
|
+
'x=0&x[]=1',
|
|
94
|
+
'x[]=1&x=s',
|
|
95
|
+
'a%5Bb%5D=1',
|
|
96
|
+
'n=+1',
|
|
97
|
+
'e=',
|
|
98
|
+
'k=1&k=2&k[]=3',
|
|
99
|
+
'arr[0]=a&arr[1]=b',
|
|
100
|
+
'arr[]=a&arr[2]=b',
|
|
101
|
+
'a[b][]=1&a[b][]=2',
|
|
102
|
+
'a[b]=1&a=2',
|
|
103
|
+
'a=2&a[b]=1',
|
|
104
|
+
'x[1][2]=3',
|
|
105
|
+
'a=1&&b=2',
|
|
106
|
+
'a[]=1&a[0]=9',
|
|
107
|
+
'A=1&a=2',
|
|
108
|
+
'a[0][0]=1',
|
|
109
|
+
'foo=bar%20baz',
|
|
110
|
+
'arr[a][b][c][d]=1',
|
|
111
|
+
'k[]=1&k[a]=2',
|
|
112
|
+
'a=b%26c',
|
|
113
|
+
'k[n][]=1',
|
|
114
|
+
'k[][]=1',
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// PHP's parse_str walks the *decoded* name as a C string, so a NUL byte (%00)
|
|
118
|
+
// truncates the variable name. The compiled rung keeps the NUL instead
|
|
119
|
+
// (it is a JS string, not a NUL-terminated buffer). The direction is
|
|
120
|
+
// fail-closed: `_token%00x=...` is *not* seen as `_token` by the compiled rung,
|
|
121
|
+
// so the CSRF check rejects the request rather than accepting it. Pinned here
|
|
122
|
+
// so the divergence stays explicit and is never widened by accident.
|
|
123
|
+
const NUL_CASES = [
|
|
124
|
+
['a%00b=1', { a: '1' }],
|
|
125
|
+
['%00a=1', []],
|
|
126
|
+
['a[b%00c]=1', { a_b: '1' }],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
function phpParseStr(cases) {
|
|
130
|
+
const code = [
|
|
131
|
+
'$cases = json_decode(stream_get_contents(STDIN), true);',
|
|
132
|
+
'$out = [];',
|
|
133
|
+
'foreach ($cases as $c) { parse_str($c, $r); $out[] = $r; }',
|
|
134
|
+
'echo json_encode($out);',
|
|
135
|
+
].join(' ');
|
|
136
|
+
const res = spawnSync(PHP, ['-r', code], {
|
|
137
|
+
input: JSON.stringify(cases),
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
140
|
+
});
|
|
141
|
+
if (res.error || res.status !== 0) {
|
|
142
|
+
throw new Error(`php parse_str probe failed: ${res.error?.message ?? res.stderr}`);
|
|
143
|
+
}
|
|
144
|
+
return JSON.parse(res.stdout);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test('parsePhpQuery matches PHP 8.3 parse_str exactly', () => {
|
|
148
|
+
const php = phpParseStr(MATCH_CASES);
|
|
149
|
+
for (let i = 0; i < MATCH_CASES.length; i += 1) {
|
|
150
|
+
const js = JSON.parse(JSON.stringify(parsePhpQuery(MATCH_CASES[i])));
|
|
151
|
+
assert.deepEqual(js, php[i], `parsePhpQuery diverged for ${MATCH_CASES[i]}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('parsePhpQuery NUL-in-name divergence is explicit and fail-closed', () => {
|
|
156
|
+
const cases = NUL_CASES.map(([c]) => c);
|
|
157
|
+
const php = phpParseStr(cases);
|
|
158
|
+
for (let i = 0; i < cases.length; i += 1) {
|
|
159
|
+
const js = JSON.parse(JSON.stringify(parsePhpQuery(cases[i])));
|
|
160
|
+
// PHP's value is pinned: it is the C-string-truncation artifact.
|
|
161
|
+
assert.deepEqual(php[i], NUL_CASES[i][1], `pinned PHP output drifted for ${cases[i]}`);
|
|
162
|
+
assert.notDeepEqual(
|
|
163
|
+
js,
|
|
164
|
+
php[i],
|
|
165
|
+
`parsePhpQuery now matches PHP for ${cases[i]} — move it to MATCH_CASES and update MAP.md`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 2. the pipeline scenario
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// Keep this table/labels in lockstep with tools/php-http-pipeline-oracle.php:
|
|
175
|
+
// the oracle registers exactly these routes and probes only these observation
|
|
176
|
+
// points, so the compiled handlers below are the same functions in JS.
|
|
177
|
+
const STEPS = [
|
|
178
|
+
{ label: '1 GET state anon', method: 'GET', uri: '/__probe/state' },
|
|
179
|
+
{ label: '2 GET state jar', method: 'GET', uri: '/__probe/state', jar: true },
|
|
180
|
+
{ label: '3 POST state no csrf', method: 'POST', uri: '/__probe/state', jar: true },
|
|
181
|
+
{
|
|
182
|
+
label: '4 POST state csrf',
|
|
183
|
+
method: 'POST',
|
|
184
|
+
uri: '/__probe/state',
|
|
185
|
+
jar: true,
|
|
186
|
+
form: { _token: '@token' },
|
|
187
|
+
},
|
|
188
|
+
{ label: '5 GET auth json', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
189
|
+
{
|
|
190
|
+
label: '6 GET auth html',
|
|
191
|
+
method: 'GET',
|
|
192
|
+
uri: '/__probe/auth',
|
|
193
|
+
jar: true,
|
|
194
|
+
headers: { Accept: 'text/html' },
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
label: '7 POST login',
|
|
198
|
+
method: 'POST',
|
|
199
|
+
uri: '/__probe/login',
|
|
200
|
+
jar: true,
|
|
201
|
+
form: { _token: '@token', email: 'u@example.com', password: 'secret' },
|
|
202
|
+
},
|
|
203
|
+
{ label: '8 GET auth json after login', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
204
|
+
{
|
|
205
|
+
label: '9 GET guest html',
|
|
206
|
+
method: 'GET',
|
|
207
|
+
uri: '/__probe/guest',
|
|
208
|
+
jar: true,
|
|
209
|
+
headers: { Accept: 'text/html' },
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
label: '10 POST logout',
|
|
213
|
+
method: 'POST',
|
|
214
|
+
uri: '/__probe/logout',
|
|
215
|
+
jar: true,
|
|
216
|
+
form: { _token: '@token' },
|
|
217
|
+
},
|
|
218
|
+
{ label: '11 GET auth json after logout', method: 'GET', uri: '/__probe/auth', jar: true },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
function runOracle() {
|
|
222
|
+
requireApp();
|
|
223
|
+
const steps = STEPS.map((s) => ({
|
|
224
|
+
label: s.label,
|
|
225
|
+
method: s.method,
|
|
226
|
+
uri: s.uri,
|
|
227
|
+
jar: Boolean(s.jar),
|
|
228
|
+
...(s.headers ? { headers: s.headers } : {}),
|
|
229
|
+
...(s.form ? { form: s.form } : {}),
|
|
230
|
+
}));
|
|
231
|
+
const res = spawnSync(PHP, [oraclePath], {
|
|
232
|
+
input: JSON.stringify({
|
|
233
|
+
app_root: APP_ROOT,
|
|
234
|
+
freeze_ms: FREEZE_MS,
|
|
235
|
+
user: { id: 1, name: 'Test User', email: 'u@example.com', password_hash: HASH12 },
|
|
236
|
+
steps,
|
|
237
|
+
}),
|
|
238
|
+
encoding: 'utf8',
|
|
239
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
240
|
+
});
|
|
241
|
+
if (res.error) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (res.status !== 0) {
|
|
247
|
+
throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
|
|
248
|
+
}
|
|
249
|
+
return JSON.parse(res.stdout);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
class LWOracleUser extends Model {}
|
|
253
|
+
LWOracleUser.table = 'lw_users';
|
|
254
|
+
applyAuthenticatable(LWOracleUser);
|
|
255
|
+
|
|
256
|
+
// Minimal D1-shaped wrapper over node:sqlite.
|
|
257
|
+
function d1(sqlite) {
|
|
258
|
+
return {
|
|
259
|
+
prepare(sql) {
|
|
260
|
+
let params = [];
|
|
261
|
+
return {
|
|
262
|
+
bind(...p) {
|
|
263
|
+
params = p;
|
|
264
|
+
return this;
|
|
265
|
+
},
|
|
266
|
+
async all() {
|
|
267
|
+
const st = sqlite.prepare(sql);
|
|
268
|
+
const rows = params.length ? st.all(...params) : st.all();
|
|
269
|
+
return { results: rows.map((r) => ({ ...r })) };
|
|
270
|
+
},
|
|
271
|
+
async first() {
|
|
272
|
+
const st = sqlite.prepare(sql);
|
|
273
|
+
const row = params.length ? st.get(...params) : st.get();
|
|
274
|
+
return row ? { ...row } : null;
|
|
275
|
+
},
|
|
276
|
+
async run() {
|
|
277
|
+
const st = sqlite.prepare(sql);
|
|
278
|
+
const info = params.length ? st.run(...params) : st.run();
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
281
|
+
meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) },
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Schema mirrors the oracle's: Laravel's default `sessions` table + the oracle's
|
|
290
|
+
// `lw_users`, seeded with the same pinned bcrypt hash.
|
|
291
|
+
function makeDb() {
|
|
292
|
+
const sqlite = new DatabaseSync(':memory:');
|
|
293
|
+
sqlite.exec(`
|
|
294
|
+
CREATE TABLE sessions (
|
|
295
|
+
id TEXT PRIMARY KEY,
|
|
296
|
+
user_id INTEGER NULL,
|
|
297
|
+
ip_address TEXT NULL,
|
|
298
|
+
user_agent TEXT NULL,
|
|
299
|
+
payload TEXT NOT NULL,
|
|
300
|
+
last_activity INTEGER NOT NULL
|
|
301
|
+
);
|
|
302
|
+
CREATE TABLE lw_users (
|
|
303
|
+
id INTEGER PRIMARY KEY,
|
|
304
|
+
name TEXT NULL,
|
|
305
|
+
email TEXT UNIQUE,
|
|
306
|
+
email_verified_at TEXT NULL,
|
|
307
|
+
password TEXT NOT NULL,
|
|
308
|
+
remember_token TEXT NULL,
|
|
309
|
+
created_at TEXT NULL,
|
|
310
|
+
updated_at TEXT NULL
|
|
311
|
+
);
|
|
312
|
+
`);
|
|
313
|
+
sqlite
|
|
314
|
+
.prepare(
|
|
315
|
+
`INSERT INTO lw_users
|
|
316
|
+
(id, name, email, email_verified_at, password, remember_token, created_at, updated_at)
|
|
317
|
+
VALUES (1, 'Test User', 'u@example.com', NULL, ?, NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00')`
|
|
318
|
+
)
|
|
319
|
+
.run(HASH12);
|
|
320
|
+
return { sqlite, db: d1(sqlite) };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Synthetic route table. `routeKey` = `methods[0] + ' ' + uri`, so GET+HEAD
|
|
324
|
+
// routes key on the GET handler (exactly what a Laravel `Route::get()` produces).
|
|
325
|
+
const ROUTES = [
|
|
326
|
+
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web'] },
|
|
327
|
+
{ uri: '/__probe/state', methods: ['POST'], middleware: ['web'] },
|
|
328
|
+
{ uri: '/__probe/auth', methods: ['GET', 'HEAD'], middleware: ['web', 'auth'] },
|
|
329
|
+
{ uri: '/__probe/basic', methods: ['GET', 'HEAD'], middleware: ['web', 'auth.basic'] },
|
|
330
|
+
{ uri: '/__probe/guest', methods: ['GET', 'HEAD'], middleware: ['web', 'guest'] },
|
|
331
|
+
{ uri: '/__probe/login', methods: ['POST'], middleware: ['web'] },
|
|
332
|
+
{ uri: '/__probe/logout', methods: ['POST'], middleware: ['web'] },
|
|
333
|
+
{ uri: '/login', name: 'login', methods: ['GET', 'HEAD'], middleware: [] },
|
|
334
|
+
// The oracle app defines a named `dashboard` route, and
|
|
335
|
+
// RedirectIfAuthenticated::defaultRedirectUri() probes for it first — so the
|
|
336
|
+
// synthetic table has to mirror that name or `guest` redirects to `/`.
|
|
337
|
+
{ uri: '/dashboard', name: 'dashboard', methods: ['GET', 'HEAD'], middleware: [] },
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
function json(data, status = 200) {
|
|
341
|
+
return { status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(data) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function guardId(ctx) {
|
|
345
|
+
const user = await ctx.auth.guard().user();
|
|
346
|
+
return user ? user.getAuthIdentifier() : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function stateBody(ctx) {
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
token: ctx.session.token(),
|
|
353
|
+
keys: Object.keys(ctx.session.all()),
|
|
354
|
+
user: null, // filled below (async)
|
|
355
|
+
path: ctx.request.path(),
|
|
356
|
+
previous: ctx.session.previousUrl(),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function stateHandler(ctx) {
|
|
361
|
+
const body = stateBody(ctx);
|
|
362
|
+
body.user = await guardId(ctx);
|
|
363
|
+
return json(body);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Mirrors the oracle's probe bodies — every one carries the *current* session
|
|
367
|
+
// token so `@token` resolves from the immediately preceding response (login and
|
|
368
|
+
// logout regenerate the CSRF token).
|
|
369
|
+
const handlers = {
|
|
370
|
+
'GET /__probe/state': stateHandler,
|
|
371
|
+
'POST /__probe/state': stateHandler,
|
|
372
|
+
'GET /__probe/auth': async (ctx) =>
|
|
373
|
+
json({ user: await guardId(ctx), token: ctx.session.token() }),
|
|
374
|
+
'GET /__probe/basic': async (ctx) =>
|
|
375
|
+
json({ user: await guardId(ctx), token: ctx.session.token() }),
|
|
376
|
+
'GET /__probe/guest': async (ctx) =>
|
|
377
|
+
json({ guest: true, token: ctx.session.token() }),
|
|
378
|
+
'POST /__probe/login': async (ctx) => {
|
|
379
|
+
const ok = await ctx.auth
|
|
380
|
+
.guard()
|
|
381
|
+
.attempt({ email: ctx.request.input('email'), password: ctx.request.input('password') });
|
|
382
|
+
return json({ ok, user: await guardId(ctx), token: ctx.session.token() });
|
|
383
|
+
},
|
|
384
|
+
'POST /__probe/logout': async (ctx) => {
|
|
385
|
+
await ctx.auth.guard().logout();
|
|
386
|
+
return json({ ok: true, user: await guardId(ctx), token: ctx.session.token() });
|
|
387
|
+
},
|
|
388
|
+
'GET /login': () => ({
|
|
389
|
+
status: 200,
|
|
390
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
391
|
+
body: 'login',
|
|
392
|
+
}),
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
function serializeJar(jar) {
|
|
396
|
+
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --------------------------------------------------------------- comparisons
|
|
400
|
+
|
|
401
|
+
function normHeaders(headers) {
|
|
402
|
+
const out = {};
|
|
403
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
404
|
+
const key = String(name).toLowerCase();
|
|
405
|
+
if (key === 'date' || key === 'set-cookie') continue;
|
|
406
|
+
out[key] = Array.isArray(value) ? String(value[0]) : String(value);
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Normalise the random session token out of a probe JSON body.
|
|
412
|
+
function normJsonBody(parsed) {
|
|
413
|
+
if (parsed === null || typeof parsed !== 'object') return parsed;
|
|
414
|
+
const out = { ...parsed };
|
|
415
|
+
if (typeof out.token === 'string') out.token = TOKEN;
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function parseSetCookie(raw) {
|
|
420
|
+
const parts = String(raw).split('; ');
|
|
421
|
+
const first = parts[0];
|
|
422
|
+
const eq = first.indexOf('=');
|
|
423
|
+
const name = decodeURIComponent(first.slice(0, eq));
|
|
424
|
+
const value = decodeURIComponent(first.slice(eq + 1));
|
|
425
|
+
// Keep attribute order (parity), normalise only the Max-Age value.
|
|
426
|
+
const attrs = parts.slice(1).map((a) => (a.toLowerCase().startsWith('max-age=') ? 'max-age=*' : a));
|
|
427
|
+
return { name, value, attrs };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normCookies(list) {
|
|
431
|
+
return list.map(parseSetCookie).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function decodePayload(b64) {
|
|
435
|
+
return Buffer.from(String(b64), 'base64').toString('utf8');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Normalise the payload bytes: only the CSRF token attribute is random on both
|
|
439
|
+
// sides (the session id lives in the row `id` column, never inside the payload).
|
|
440
|
+
// Everything else — attribute order, `/`-escaping, flash shape — must be
|
|
441
|
+
// byte-identical.
|
|
442
|
+
function normPayloadText(text, token) {
|
|
443
|
+
if (!token) return text;
|
|
444
|
+
return text.split(token).join(TOKEN);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function normRow(row, token) {
|
|
448
|
+
const payload = decodePayload(row.payload);
|
|
449
|
+
let keys = null;
|
|
450
|
+
try {
|
|
451
|
+
keys = Object.keys(JSON.parse(payload)).sort();
|
|
452
|
+
} catch {
|
|
453
|
+
keys = null;
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
id_length: String(row.id).length,
|
|
457
|
+
user_id: row.user_id === null || row.user_id === undefined ? null : Number(row.user_id),
|
|
458
|
+
ip_address: row.ip_address,
|
|
459
|
+
user_agent: row.user_agent,
|
|
460
|
+
last_activity: Number(row.last_activity),
|
|
461
|
+
keys,
|
|
462
|
+
payload: normPayloadText(payload, token),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ------------------------------------------------------------------ JS replay
|
|
467
|
+
|
|
468
|
+
async function runJs(config) {
|
|
469
|
+
const { sqlite, db } = makeDb();
|
|
470
|
+
const table = normalizeTable(ROUTES);
|
|
471
|
+
const models = { LWOracleUser };
|
|
472
|
+
const jar = new Map();
|
|
473
|
+
let lastToken = null;
|
|
474
|
+
const out = [];
|
|
475
|
+
|
|
476
|
+
for (const step of STEPS) {
|
|
477
|
+
const headers = { host: 'localhost', 'user-agent': USER_AGENT, accept: 'application/json' };
|
|
478
|
+
if (step.headers?.Accept) headers.accept = step.headers.Accept;
|
|
479
|
+
if (jar.size) headers.cookie = serializeJar(jar);
|
|
480
|
+
|
|
481
|
+
let body = '';
|
|
482
|
+
if (step.form) {
|
|
483
|
+
const form = {};
|
|
484
|
+
for (const [k, v] of Object.entries(step.form)) form[k] = v === '@token' ? lastToken : v;
|
|
485
|
+
body = new URLSearchParams(form).toString();
|
|
486
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const res = await handleHttp(
|
|
490
|
+
{ method: step.method, url: BASE + step.uri, headers, body },
|
|
491
|
+
{
|
|
492
|
+
table,
|
|
493
|
+
handlers,
|
|
494
|
+
config,
|
|
495
|
+
env: { DB: db },
|
|
496
|
+
db,
|
|
497
|
+
models,
|
|
498
|
+
clock: () => FREEZE_S,
|
|
499
|
+
randomString: (n) => 'A'.repeat(n),
|
|
500
|
+
strRandom: (n) => 'A'.repeat(n),
|
|
501
|
+
clientIp: CLIENT_IP,
|
|
502
|
+
}
|
|
503
|
+
);
|
|
504
|
+
if (!res) throw new Error(`compiled rung returned null for ${step.label} (no handler matched)`);
|
|
505
|
+
|
|
506
|
+
for (const sc of res.setCookie) {
|
|
507
|
+
const c = parseSetCookie(sc);
|
|
508
|
+
const value = c.value;
|
|
509
|
+
if (value === '' && /expires=/i.test(sc) && /max-age=0/i.test(sc)) jar.delete(c.name);
|
|
510
|
+
else jar.set(c.name, value);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let parsed = null;
|
|
514
|
+
try {
|
|
515
|
+
parsed = JSON.parse(res.body);
|
|
516
|
+
} catch {
|
|
517
|
+
parsed = null;
|
|
518
|
+
}
|
|
519
|
+
if (parsed && typeof parsed.token === 'string') lastToken = parsed.token;
|
|
520
|
+
|
|
521
|
+
const rows = sqlite
|
|
522
|
+
.prepare('SELECT * FROM sessions ORDER BY id')
|
|
523
|
+
.all()
|
|
524
|
+
.map((r) => ({ ...r }));
|
|
525
|
+
|
|
526
|
+
out.push({
|
|
527
|
+
label: step.label,
|
|
528
|
+
status: res.status,
|
|
529
|
+
headers: res.headers,
|
|
530
|
+
setCookie: res.setCookie,
|
|
531
|
+
body: res.body,
|
|
532
|
+
parsed,
|
|
533
|
+
rows,
|
|
534
|
+
jar: new Map(jar),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function normPhpStep(step) {
|
|
541
|
+
const rawRows = (step.session_rows || []).map((r) => ({
|
|
542
|
+
id: r.id,
|
|
543
|
+
user_id: r.user_id,
|
|
544
|
+
ip_address: r.ip_address,
|
|
545
|
+
user_agent: r.user_agent,
|
|
546
|
+
payload: r.payload,
|
|
547
|
+
last_activity: r.last_activity,
|
|
548
|
+
}));
|
|
549
|
+
return {
|
|
550
|
+
label: step.label,
|
|
551
|
+
status: step.status,
|
|
552
|
+
headers: step.headers || {},
|
|
553
|
+
setCookie: step.set_cookie || [],
|
|
554
|
+
body: String(step.body ?? ''),
|
|
555
|
+
parsed: (() => {
|
|
556
|
+
try {
|
|
557
|
+
return JSON.parse(String(step.body ?? ''));
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
})(),
|
|
562
|
+
rows: rawRows,
|
|
563
|
+
sessionPlain: step.session_plain ?? null,
|
|
564
|
+
xsrfPlain: step.xsrf_plain ?? null,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
test('compiled pipeline matches the PHP 8.3 web pipeline, 11 steps', { skip: noSqlite || noApp }, async () => {
|
|
569
|
+
const oracle = runOracle();
|
|
570
|
+
const config = oracle.config;
|
|
571
|
+
assert.equal(config['session.driver'], 'database', 'oracle must run the database session driver');
|
|
572
|
+
assert.equal(config['session.serialization'], 'json', 'oracle must use json session serialization');
|
|
573
|
+
|
|
574
|
+
const js = await runJs(config);
|
|
575
|
+
assert.equal(js.length, STEPS.length);
|
|
576
|
+
const encryptor = createCookieEncryptor({
|
|
577
|
+
keyBase64: String(config['app.key'] ?? '').replace(/^base64:/, ''),
|
|
578
|
+
except: config['cookies.except'] ?? [],
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
for (let i = 0; i < STEPS.length; i += 1) {
|
|
582
|
+
const mine = js[i];
|
|
583
|
+
const php = normPhpStep(oracle.steps[i]);
|
|
584
|
+
const where = `step ${php.label}`;
|
|
585
|
+
|
|
586
|
+
// --- status ---------------------------------------------------------
|
|
587
|
+
assert.equal(mine.status, php.status, `${where}: status`);
|
|
588
|
+
|
|
589
|
+
// --- headers (minus date/set-cookie) --------------------------------
|
|
590
|
+
assert.deepEqual(normHeaders(mine.headers), normHeaders(php.headers), `${where}: headers`);
|
|
591
|
+
assert.equal(
|
|
592
|
+
String(normHeaders(mine.headers)['cache-control']),
|
|
593
|
+
'no-cache, private',
|
|
594
|
+
`${where}: cache-control must be the Symfony default`
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// --- Set-Cookie -----------------------------------------------------
|
|
598
|
+
assert.deepEqual(
|
|
599
|
+
normCookies(mine.setCookie).map((c) => ({ name: c.name, attrs: c.attrs })),
|
|
600
|
+
normCookies(php.setCookie).map((c) => ({ name: c.name, attrs: c.attrs })),
|
|
601
|
+
`${where}: Set-Cookie name+attributes (order preserved)`
|
|
602
|
+
);
|
|
603
|
+
for (const c of normCookies(mine.setCookie)) {
|
|
604
|
+
assert.ok(c.value.length > 0, `${where}: cookie ${c.name} must carry a value`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// --- body -----------------------------------------------------------
|
|
608
|
+
const ct = String(normHeaders(php.headers)['content-type'] || '');
|
|
609
|
+
if (ct.startsWith('application/json')) {
|
|
610
|
+
if (php.status === 419) {
|
|
611
|
+
// Laravel's debug 419 body embeds absolute file/line/trace paths.
|
|
612
|
+
assert.equal(mine.parsed?.message, php.parsed?.message, `${where}: 419 message`);
|
|
613
|
+
} else {
|
|
614
|
+
assert.deepEqual(normJsonBody(mine.parsed), normJsonBody(php.parsed), `${where}: JSON body`);
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
assert.equal(mine.body, php.body, `${where}: body`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- session id / CSRF token (random on both sides) -----------------
|
|
621
|
+
const sid = php.sessionPlain;
|
|
622
|
+
const xsrf = php.xsrfPlain;
|
|
623
|
+
assert.ok(sid, `${where}: oracle must report session_plain`);
|
|
624
|
+
assert.equal(String(sid).length, 40, `${where}: oracle session id length`);
|
|
625
|
+
assert.ok(xsrf, `${where}: oracle must report xsrf_plain`);
|
|
626
|
+
assert.equal(String(xsrf).length, 40, `${where}: oracle CSRF token length`);
|
|
627
|
+
|
|
628
|
+
const jsSid = await jarPlain(encryptor, mine.jar, config['session.cookie']);
|
|
629
|
+
const jsXsrf = await jarPlain(encryptor, mine.jar, 'XSRF-TOKEN');
|
|
630
|
+
assert.equal(String(jsSid).length, 40, `${where}: compiled session id length`);
|
|
631
|
+
assert.equal(String(jsXsrf).length, 40, `${where}: compiled CSRF token length`);
|
|
632
|
+
|
|
633
|
+
// --- session row + payload bytes ------------------------------------
|
|
634
|
+
// Each side is normalised with its *own* two random values, so the payload
|
|
635
|
+
// bytes must then be byte-identical (attribute order, `/`-escaping, flash
|
|
636
|
+
// shape, login/remember keys, everything).
|
|
637
|
+
const mineRows = mine.rows.map((r) => normRow(r, jsXsrf));
|
|
638
|
+
const phpRows = php.rows.map((r) => normRow(r, xsrf));
|
|
639
|
+
assert.equal(mineRows.length, phpRows.length, `${where}: session row count`);
|
|
640
|
+
assert.deepEqual(mineRows, phpRows, `${where}: session rows (payload bytes + columns)`);
|
|
641
|
+
|
|
642
|
+
// --- cookie jar -----------------------------------------------------
|
|
643
|
+
const phpJar = Object.keys(oracle.steps[i].jar_after || {}).sort();
|
|
644
|
+
const jsJar = [...mine.jar.keys()].sort();
|
|
645
|
+
assert.deepEqual(jsJar, phpJar, `${where}: cookie jar names`);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// Decrypt a request-cookie value out of a jar with the EncryptCookies scheme.
|
|
650
|
+
async function jarPlain(encryptor, jar, name) {
|
|
651
|
+
const raw = jar.get(name);
|
|
652
|
+
if (raw === undefined) return null;
|
|
653
|
+
const out = await encryptor.decryptRequestCookies({ [name]: raw });
|
|
654
|
+
return out[name];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// 3. fail-loud guards
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
async function callPipeline(mutate) {
|
|
662
|
+
const { db } = makeDb();
|
|
663
|
+
const config = baseConfig();
|
|
664
|
+
const table = normalizeTable(ROUTES);
|
|
665
|
+
const options = {
|
|
666
|
+
table,
|
|
667
|
+
handlers,
|
|
668
|
+
config,
|
|
669
|
+
env: { DB: db },
|
|
670
|
+
db,
|
|
671
|
+
models: { LWOracleUser },
|
|
672
|
+
clock: () => FREEZE_S,
|
|
673
|
+
randomString: (n) => 'A'.repeat(n),
|
|
674
|
+
strRandom: (n) => 'A'.repeat(n),
|
|
675
|
+
clientIp: CLIENT_IP,
|
|
676
|
+
};
|
|
677
|
+
if (mutate) mutate(options);
|
|
678
|
+
return handleHttp(
|
|
679
|
+
{
|
|
680
|
+
method: 'GET',
|
|
681
|
+
url: BASE + '/__probe/state',
|
|
682
|
+
headers: { host: 'localhost', accept: 'application/json' },
|
|
683
|
+
body: '',
|
|
684
|
+
},
|
|
685
|
+
options
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function baseConfig() {
|
|
690
|
+
return {
|
|
691
|
+
'app.key': 'base64:3xd/I1bUWfA8Rugdso1ILBcgFHCBKkPtC8GLvAsSYHY=',
|
|
692
|
+
'app.url': 'http://localhost',
|
|
693
|
+
'session.driver': 'database',
|
|
694
|
+
'session.cookie': 'laravel-session',
|
|
695
|
+
'session.lifetime': 120,
|
|
696
|
+
'session.path': '/',
|
|
697
|
+
'session.domain': null,
|
|
698
|
+
'session.secure': null,
|
|
699
|
+
'session.http_only': true,
|
|
700
|
+
'session.same_site': 'lax',
|
|
701
|
+
'session.serialization': 'json',
|
|
702
|
+
'session.table': 'sessions',
|
|
703
|
+
'session.encrypt': false,
|
|
704
|
+
'cookies.except': [],
|
|
705
|
+
'csrf.except': [],
|
|
706
|
+
'auth.defaults.guard': 'web',
|
|
707
|
+
'auth.guards.web': { driver: 'session', provider: 'users' },
|
|
708
|
+
'auth.providers.users': { driver: 'eloquent', model: 'LWOracleUser' },
|
|
709
|
+
'auth.hash_key': 'base64:3xd/I1bUWfA8Rugdso1ILBcgFHCBKkPtC8GLvAsSYHY=',
|
|
710
|
+
'auth.timebox_duration': 200000,
|
|
711
|
+
'auth.rehash_on_login': true,
|
|
712
|
+
'hashing.driver': 'bcrypt',
|
|
713
|
+
'hashing.bcrypt.rounds': '12',
|
|
714
|
+
'hashing.bcrypt.verify': true,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
test('fail loud: unknown route middleware is rejected', async () => {
|
|
719
|
+
await assert.rejects(
|
|
720
|
+
callPipeline((o) => {
|
|
721
|
+
o.table = normalizeTable([
|
|
722
|
+
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'can:update,post'] },
|
|
723
|
+
]);
|
|
724
|
+
}),
|
|
725
|
+
/not supported by the compiled rung/
|
|
726
|
+
);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('fail loud: throttle middleware without a cache store', async () => {
|
|
730
|
+
await assert.rejects(
|
|
731
|
+
callPipeline((o) => {
|
|
732
|
+
o.table = normalizeTable([
|
|
733
|
+
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'throttle:60,1'] },
|
|
734
|
+
]);
|
|
735
|
+
}),
|
|
736
|
+
/cache store/
|
|
737
|
+
);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// The method-not-allowed path (Laravel's AbstractRouteCollection):
|
|
742
|
+
// - an OPTIONS request gets the automatic OPTIONS response (200, an empty
|
|
743
|
+
// body, the Allow header with the verbs the URI accepts);
|
|
744
|
+
// - any other method raises MethodNotAllowedHttpException: 405 when the app
|
|
745
|
+
// has an errors/405 view, else the framework's error renderer answers 500.
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
async function callMethod(method, uri, mutate) {
|
|
749
|
+
const { db } = makeDb();
|
|
750
|
+
const config = baseConfig();
|
|
751
|
+
const options = {
|
|
752
|
+
table: normalizeTable(ROUTES),
|
|
753
|
+
handlers,
|
|
754
|
+
config,
|
|
755
|
+
env: { DB: db },
|
|
756
|
+
db,
|
|
757
|
+
models: { LWOracleUser },
|
|
758
|
+
clock: () => FREEZE_S,
|
|
759
|
+
randomString: (n) => 'A'.repeat(n),
|
|
760
|
+
strRandom: (n) => 'A'.repeat(n),
|
|
761
|
+
clientIp: CLIENT_IP,
|
|
762
|
+
};
|
|
763
|
+
if (mutate) mutate(options);
|
|
764
|
+
return handleHttp(
|
|
765
|
+
{ method, url: BASE + uri, headers: { host: 'localhost' }, body: '' },
|
|
766
|
+
options
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
test('router: an OPTIONS request gets the automatic OPTIONS response', async () => {
|
|
771
|
+
const res = await callMethod('OPTIONS', '/__probe/state', (o) => {
|
|
772
|
+
o.table = normalizeTable([
|
|
773
|
+
{ uri: '/__probe/state', methods: ['GET', 'HEAD'] },
|
|
774
|
+
{ uri: '/__probe/state', methods: ['POST'] },
|
|
775
|
+
]);
|
|
776
|
+
});
|
|
777
|
+
assert.equal(res.status, 200);
|
|
778
|
+
assert.equal(res.body, '');
|
|
779
|
+
// Laravel: implode(',', $methods) - the verbs the URI accepts, no spaces.
|
|
780
|
+
assert.equal(res.headers.allow, 'GET,HEAD,POST');
|
|
781
|
+
assert.equal(res.headers['content-type'], 'text/html; charset=utf-8');
|
|
782
|
+
assert.equal(res.headers['cache-control'], 'no-cache, private');
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('router: a method that is not allowed answers like Laravel', async () => {
|
|
786
|
+
const res = await callMethod('DELETE', '/__probe/state', (o) => {
|
|
787
|
+
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
788
|
+
});
|
|
789
|
+
// No errors/405 view in the app: the framework's error renderer answers 500.
|
|
790
|
+
assert.equal(res.status, 500);
|
|
791
|
+
assert.equal(res.headers.allow, undefined);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test('router: a method that is not allowed answers 405 with an errors/405 view', async () => {
|
|
795
|
+
const res = await callMethod('DELETE', '/__probe/state', (o) => {
|
|
796
|
+
o.config['errors.405_view'] = true;
|
|
797
|
+
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
798
|
+
});
|
|
799
|
+
assert.equal(res.status, 405);
|
|
800
|
+
// Symfony's MethodNotAllowedHttpException: implode(', ', $allowedMethods).
|
|
801
|
+
assert.equal(res.headers.allow, 'GET, HEAD');
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('router: a URI that matches no route is left to the platform', async () => {
|
|
805
|
+
const res = await callMethod('GET', '/__probe/nothing', (o) => {
|
|
806
|
+
o.table = normalizeTable([{ uri: '/__probe/state', methods: ['GET', 'HEAD'] }]);
|
|
807
|
+
});
|
|
808
|
+
assert.equal(res, null);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('throttle over the D1 cache store: first N allowed, then 429', async (t) => {
|
|
812
|
+
if (noSqlite) return t.skip(noSqlite);
|
|
813
|
+
const { sqlite, db } = makeDb();
|
|
814
|
+
sqlite.exec(`
|
|
815
|
+
CREATE TABLE cache ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL, "expiration" INTEGER NOT NULL);
|
|
816
|
+
CREATE TABLE cache_locks ("key" TEXT PRIMARY KEY, "owner" TEXT NOT NULL, "expiration" INTEGER NOT NULL);
|
|
817
|
+
`);
|
|
818
|
+
const cache = createDatabaseCache({
|
|
819
|
+
db,
|
|
820
|
+
config: { 'cache.table': 'cache', 'cache.prefix': 'laravel-cache-', 'cache.lock_table': 'cache_locks' },
|
|
821
|
+
});
|
|
822
|
+
const table = normalizeTable([
|
|
823
|
+
{ uri: '/__probe/state', methods: ['GET', 'HEAD'], middleware: ['web', 'throttle:2,1'] },
|
|
824
|
+
]);
|
|
825
|
+
const call = () =>
|
|
826
|
+
handleHttp(
|
|
827
|
+
{
|
|
828
|
+
method: 'GET',
|
|
829
|
+
url: BASE + '/__probe/state',
|
|
830
|
+
headers: { host: 'localhost', accept: 'application/json' },
|
|
831
|
+
body: '',
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
table,
|
|
835
|
+
handlers: { 'GET /__probe/state': handlers['GET /__probe/state'] },
|
|
836
|
+
config: baseConfig(),
|
|
837
|
+
env: { DB: db },
|
|
838
|
+
db,
|
|
839
|
+
cache,
|
|
840
|
+
models: { LWOracleUser },
|
|
841
|
+
clock: () => FREEZE_S,
|
|
842
|
+
randomString: (n) => 'A'.repeat(n),
|
|
843
|
+
strRandom: (n) => 'A'.repeat(n),
|
|
844
|
+
clientIp: CLIENT_IP,
|
|
845
|
+
}
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
const r1 = await call();
|
|
849
|
+
const r2 = await call();
|
|
850
|
+
const r3 = await call();
|
|
851
|
+
assert.equal(r1.status, 200, 'hit #1 within throttle:2,1');
|
|
852
|
+
assert.equal(r2.status, 200, 'hit #2 within throttle:2,1');
|
|
853
|
+
assert.equal(r3.status, 429, 'hit #3 exceeds throttle:2,1');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// The `auth.basic` middleware (Illuminate\Auth\Middleware\AuthenticateWithBasicAuth):
|
|
857
|
+
// the Basic credentials are verified and the user is logged in; a failure is a 401
|
|
858
|
+
// with Symfony's `WWW-Authenticate: Basic realm="Basic"` challenge.
|
|
859
|
+
function basicHeader(user, password) {
|
|
860
|
+
return 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
test('auth.basic: a missing Basic header is a 401 with the challenge', async () => {
|
|
864
|
+
const { db } = makeDb();
|
|
865
|
+
const res = await handleHttp(
|
|
866
|
+
{ method: 'GET', url: BASE + '/__probe/basic', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
867
|
+
{
|
|
868
|
+
table: normalizeTable(ROUTES),
|
|
869
|
+
handlers,
|
|
870
|
+
config: baseConfig(),
|
|
871
|
+
env: { DB: db },
|
|
872
|
+
db,
|
|
873
|
+
models: { LWOracleUser },
|
|
874
|
+
clock: () => FREEZE_S,
|
|
875
|
+
clientIp: CLIENT_IP,
|
|
876
|
+
}
|
|
877
|
+
);
|
|
878
|
+
assert.equal(res.status, 401);
|
|
879
|
+
// Symfony's UnauthorizedHttpException('Basic') — the challenge is the
|
|
880
|
+
// exception's argument, so the header is exactly `Basic`.
|
|
881
|
+
assert.equal(res.headers['www-authenticate'], 'Basic');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test('auth.basic: wrong credentials are a 401, valid credentials log the user in', async () => {
|
|
885
|
+
const { db } = makeDb();
|
|
886
|
+
const call = (authorization) =>
|
|
887
|
+
handleHttp(
|
|
888
|
+
{
|
|
889
|
+
method: 'GET',
|
|
890
|
+
url: BASE + '/__probe/basic',
|
|
891
|
+
headers: { host: 'localhost', accept: 'application/json', authorization },
|
|
892
|
+
body: '',
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
table: normalizeTable(ROUTES),
|
|
896
|
+
handlers,
|
|
897
|
+
config: baseConfig(),
|
|
898
|
+
env: { DB: db },
|
|
899
|
+
db,
|
|
900
|
+
models: { LWOracleUser },
|
|
901
|
+
clock: () => FREEZE_S,
|
|
902
|
+
clientIp: CLIENT_IP,
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const bad = await call(basicHeader('u@example.com', 'wrong'));
|
|
907
|
+
assert.equal(bad.status, 401);
|
|
908
|
+
assert.equal(bad.headers['www-authenticate'], 'Basic');
|
|
909
|
+
|
|
910
|
+
const ok = await call(basicHeader('u@example.com', 'secret'));
|
|
911
|
+
assert.equal(ok.status, 200);
|
|
912
|
+
assert.equal(JSON.parse(ok.body).user, 1);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test('the service adapters (storage/mail/queue) reach the handler context', async () => {
|
|
916
|
+
const { db } = makeDb();
|
|
917
|
+
const sent = [];
|
|
918
|
+
const storage = {
|
|
919
|
+
async put(k, v) { sent.push(['put', k, v]); return k; },
|
|
920
|
+
async getText(k) { return sent.find((s) => s[1] === k) ? 'body' : null; },
|
|
921
|
+
async exists(k) { return sent.some((s) => s[1] === k); },
|
|
922
|
+
async delete(k) { sent.push(['delete', k]); },
|
|
923
|
+
};
|
|
924
|
+
const mail = { async send(m) { sent.push(['mail', m.to]); return { id: 'x' }; } };
|
|
925
|
+
const queue = { async push(q, p) { sent.push(['queue', q, p]); return { id: 'q' }; } };
|
|
926
|
+
const table = normalizeTable([{ uri: '/__probe/services', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
927
|
+
const res = await handleHttp(
|
|
928
|
+
{ method: 'GET', url: BASE + '/__probe/services', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
929
|
+
{
|
|
930
|
+
table,
|
|
931
|
+
handlers: {
|
|
932
|
+
'GET /__probe/services': async (ctx) => {
|
|
933
|
+
await ctx.storage.put('a.txt', 'x');
|
|
934
|
+
const v = await ctx.storage.getText('a.txt');
|
|
935
|
+
await ctx.mail.send({ to: 'a@b.co' });
|
|
936
|
+
await ctx.queue.push('default', { id: 1 });
|
|
937
|
+
return json({ v, sent: sent.length });
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
config: baseConfig(),
|
|
941
|
+
env: { DB: db },
|
|
942
|
+
db,
|
|
943
|
+
models: { LWOracleUser },
|
|
944
|
+
storage,
|
|
945
|
+
mail,
|
|
946
|
+
queue,
|
|
947
|
+
clock: () => FREEZE_S,
|
|
948
|
+
clientIp: CLIENT_IP,
|
|
949
|
+
}
|
|
950
|
+
);
|
|
951
|
+
assert.equal(res.status, 200);
|
|
952
|
+
assert.equal(JSON.parse(res.body).v, 'body');
|
|
953
|
+
assert.deepEqual(
|
|
954
|
+
sent.map((s) => s[0]),
|
|
955
|
+
['put', 'mail', 'queue']
|
|
956
|
+
);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
test('fail loud: session.encrypt=true and unknown drivers', async () => {
|
|
960
|
+
await assert.rejects(
|
|
961
|
+
callPipeline((o) => {
|
|
962
|
+
o.config['session.encrypt'] = true;
|
|
963
|
+
}),
|
|
964
|
+
/SESSION_ENCRYPT=true/
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
await assert.rejects(
|
|
968
|
+
callPipeline((o) => {
|
|
969
|
+
o.config['session.driver'] = 'redis';
|
|
970
|
+
}),
|
|
971
|
+
/session driver 'redis'/
|
|
972
|
+
);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test('routes with no compiled handler return null (assets served by the worker)', async () => {
|
|
976
|
+
const res = await callPipeline((o) => {
|
|
977
|
+
o.table = normalizeTable([{ uri: '/storage/asset.png', methods: ['GET', 'HEAD'] }]);
|
|
978
|
+
o.handlers = {};
|
|
979
|
+
});
|
|
980
|
+
assert.equal(res, null);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
984
|
+
test('status-only: missing CSRF token returns 419', async () => {
|
|
985
|
+
const { db } = makeDb();
|
|
986
|
+
const config = baseConfig();
|
|
987
|
+
const table = normalizeTable(ROUTES);
|
|
988
|
+
const res = await handleHttp(
|
|
989
|
+
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
990
|
+
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
991
|
+
);
|
|
992
|
+
assert.equal(res.status, 419);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
996
|
+
const { HttpError } = await import('../http.mjs');
|
|
997
|
+
const { db } = makeDb();
|
|
998
|
+
const config = baseConfig();
|
|
999
|
+
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1000
|
+
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1001
|
+
const res = await handleHttp(
|
|
1002
|
+
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1003
|
+
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1004
|
+
);
|
|
1005
|
+
assert.equal(res.status, 500);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test('status-only: renderErrorView hook overrides 419 body', async () => {
|
|
1009
|
+
const { db } = makeDb();
|
|
1010
|
+
const config = baseConfig();
|
|
1011
|
+
const table = normalizeTable(ROUTES);
|
|
1012
|
+
const res = await handleHttp(
|
|
1013
|
+
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1014
|
+
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP,
|
|
1015
|
+
renderErrorView: (status) => (status === 419 ? '<html><body>custom-419</body></html>' : null) }
|
|
1016
|
+
);
|
|
1017
|
+
assert.equal(res.status, 419);
|
|
1018
|
+
assert.match(res.body, /custom-419/);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
1022
|
+
test('status-only: missing CSRF token returns 419', async () => {
|
|
1023
|
+
const { db } = makeDb();
|
|
1024
|
+
const config = baseConfig();
|
|
1025
|
+
const table = normalizeTable(ROUTES);
|
|
1026
|
+
const res = await handleHttp(
|
|
1027
|
+
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1028
|
+
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1029
|
+
);
|
|
1030
|
+
assert.equal(res.status, 419);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
1034
|
+
const { HttpError } = await import('../http.mjs');
|
|
1035
|
+
const { db } = makeDb();
|
|
1036
|
+
const config = baseConfig();
|
|
1037
|
+
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1038
|
+
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1039
|
+
const res = await handleHttp(
|
|
1040
|
+
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1041
|
+
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1042
|
+
);
|
|
1043
|
+
assert.equal(res.status, 500);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// status-only smoke tests for the three interim error codes (419/429/500).
|
|
1047
|
+
test('status-only: missing CSRF token returns 419', async () => {
|
|
1048
|
+
const { db } = makeDb();
|
|
1049
|
+
const config = baseConfig();
|
|
1050
|
+
const table = normalizeTable(ROUTES);
|
|
1051
|
+
const res = await handleHttp(
|
|
1052
|
+
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1053
|
+
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1054
|
+
);
|
|
1055
|
+
assert.equal(res.status, 419);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test('status-only: handler throwing HttpError(500) renders 500', async () => {
|
|
1059
|
+
const { HttpError } = await import('../http.mjs');
|
|
1060
|
+
const { db } = makeDb();
|
|
1061
|
+
const config = baseConfig();
|
|
1062
|
+
const table = normalizeTable([{ uri: '/__probe/boom', methods: ['GET', 'HEAD'], middleware: ['web'] }]);
|
|
1063
|
+
const boomHandlers = { 'GET /__probe/boom': async () => { throw new HttpError('boom', 500); } };
|
|
1064
|
+
const res = await handleHttp(
|
|
1065
|
+
{ method: 'GET', url: BASE + '/__probe/boom', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1066
|
+
{ table, handlers: boomHandlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP }
|
|
1067
|
+
);
|
|
1068
|
+
assert.equal(res.status, 500);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('status-only: renderErrorView hook overrides 419 body', async () => {
|
|
1072
|
+
const { db } = makeDb();
|
|
1073
|
+
const config = baseConfig();
|
|
1074
|
+
const table = normalizeTable(ROUTES);
|
|
1075
|
+
const res = await handleHttp(
|
|
1076
|
+
{ method: 'POST', url: BASE + '/__probe/state', headers: { host: 'localhost', accept: 'application/json' }, body: '' },
|
|
1077
|
+
{ table, handlers, config, env: { DB: db }, db, models: { LWOracleUser }, clock: () => FREEZE_S, clientIp: CLIENT_IP,
|
|
1078
|
+
renderErrorView: (status) => (status === 419 ? '<html><body>custom-419</body></html>' : null) }
|
|
1079
|
+
);
|
|
1080
|
+
assert.equal(res.status, 419);
|
|
1081
|
+
assert.match(res.body, /custom-419/);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('request: a multipart body is parsed (the fields + the uploaded file)', () => {
|
|
1085
|
+
const boundary = '----lwBoundary';
|
|
1086
|
+
const body =
|
|
1087
|
+
`--${boundary}\r\n` +
|
|
1088
|
+
'Content-Disposition: form-data; name="title"\r\n\r\n' +
|
|
1089
|
+
'Shadow upload\r\n' +
|
|
1090
|
+
`--${boundary}\r\n` +
|
|
1091
|
+
'Content-Disposition: form-data; name="avatar"; filename="a b.txt"\r\n' +
|
|
1092
|
+
'Content-Type: text/plain\r\n\r\n' +
|
|
1093
|
+
'hello file\r\n' +
|
|
1094
|
+
`--${boundary}--\r\n`;
|
|
1095
|
+
const req = buildRequest({
|
|
1096
|
+
method: 'POST',
|
|
1097
|
+
url: 'http://localhost/upload',
|
|
1098
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
1099
|
+
body: new TextEncoder().encode(body),
|
|
1100
|
+
});
|
|
1101
|
+
assert.equal(req.input('title'), 'Shadow upload');
|
|
1102
|
+
const file = req.file('avatar');
|
|
1103
|
+
assert.equal(file.getClientOriginalName(), 'a b.txt');
|
|
1104
|
+
assert.equal(file.getClientOriginalExtension(), 'txt');
|
|
1105
|
+
assert.equal(file.getClientMimeType(), 'text/plain');
|
|
1106
|
+
assert.equal(file.getSize(), 10);
|
|
1107
|
+
assert.equal(new TextDecoder().decode(file.get()), 'hello file');
|
|
1108
|
+
assert.equal(req.hasFile('avatar'), true);
|
|
1109
|
+
assert.equal(req.hasFile('nope'), false);
|
|
1110
|
+
// allFiles(): the map of the uploaded files (Laravel's FileBag::all()).
|
|
1111
|
+
assert.equal(Object.keys(req.allFiles()).length, 1);
|
|
1112
|
+
assert.equal(req.allFiles().avatar.getClientOriginalName(), 'a b.txt');
|
|
1113
|
+
assert.equal(req.file('nope'), null);
|
|
1114
|
+
// The files are in the validated data (Laravel's all() merges them).
|
|
1115
|
+
assert.equal(req.all().avatar.getSize(), 10);
|
|
1116
|
+
// ... but the body wins on a key that is in both.
|
|
1117
|
+
assert.equal(typeof req.all().title, 'string');
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test('request: the method override follows Symfony (the header wins, A-Z only)', () => {
|
|
1121
|
+
// Symfony's getMethod(): the X-HTTP-Method-Override header wins over the
|
|
1122
|
+
// `_method` parameter (body/query), and an override that is not A-Z throws
|
|
1123
|
+
// a SuspiciousOperationException ('Invalid HTTP method override.') - which
|
|
1124
|
+
// Laravel does not map, so the answer is a 500.
|
|
1125
|
+
const req = buildRequest({
|
|
1126
|
+
method: 'POST',
|
|
1127
|
+
url: 'http://localhost/x',
|
|
1128
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-http-method-override': 'PUT' },
|
|
1129
|
+
body: '_method=DELETE',
|
|
1130
|
+
});
|
|
1131
|
+
assert.equal(req.methodOverride(), 'PUT', 'the header wins over the _method parameter');
|
|
1132
|
+
const req2 = buildRequest({
|
|
1133
|
+
method: 'POST',
|
|
1134
|
+
url: 'http://localhost/x',
|
|
1135
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
1136
|
+
body: '_method=DELETE',
|
|
1137
|
+
});
|
|
1138
|
+
assert.equal(req2.methodOverride(), 'DELETE', 'the _method parameter is honoured');
|
|
1139
|
+
assert.throws(
|
|
1140
|
+
() =>
|
|
1141
|
+
buildRequest({
|
|
1142
|
+
method: 'POST',
|
|
1143
|
+
url: 'http://localhost/x',
|
|
1144
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-http-method-override': 'PU T' },
|
|
1145
|
+
body: '',
|
|
1146
|
+
}),
|
|
1147
|
+
/Invalid HTTP method override/,
|
|
1148
|
+
);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test('request: merge() follows Laravel (the input source, replace not deep merge)', () => {
|
|
1152
|
+
// Laravel's Request::merge() (Request.php:389-401), probed:
|
|
1153
|
+
// the new input goes into the request's INPUT SOURCE (the JSON bag, the
|
|
1154
|
+
// query bag for GET/HEAD, the post bag otherwise);
|
|
1155
|
+
// every key REPLACES the value (it is NOT a deep merge: the old sub-keys
|
|
1156
|
+
// are gone) and the dot notation is honoured (Arr::set);
|
|
1157
|
+
// the other keys are kept.
|
|
1158
|
+
const req = buildRequest({
|
|
1159
|
+
method: 'POST',
|
|
1160
|
+
url: 'http://localhost/x?keep=1',
|
|
1161
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
1162
|
+
body: 'a=1&nested[x]=1&nested[y]=2',
|
|
1163
|
+
});
|
|
1164
|
+
req.merge({ a: '2', nested: { y: '9' }, b: '3', deep: { k: 'v' } });
|
|
1165
|
+
assert.equal(req.input('a'), '2', 'the key is replaced');
|
|
1166
|
+
assert.deepEqual(req.input('nested'), { y: '9' }, 'the value is replaced, not deep-merged');
|
|
1167
|
+
assert.equal(req.input('b'), '3', 'a new key is added');
|
|
1168
|
+
assert.deepEqual(req.input('deep'), { k: 'v' });
|
|
1169
|
+
// The dot notation is honoured (Arr::set): merge(['deep.k' => 'v']) sets the
|
|
1170
|
+
// NESTED value (probed against Laravel 13).
|
|
1171
|
+
req.merge({ 'deep.k2': 'w' });
|
|
1172
|
+
assert.deepEqual(req.input('deep'), { k: 'v', k2: 'w' });
|
|
1173
|
+
// The input Laravel reads is the input source UNION the query bag
|
|
1174
|
+
// (InteractsWithInput::input(): `$this->getInputSource()->all() +
|
|
1175
|
+
// $this->query->all()`) - the source's keys win.
|
|
1176
|
+
assert.equal(req.query('keep'), '1', 'the query bag is untouched');
|
|
1177
|
+
assert.equal(req.input('keep'), '1', 'the query bag is part of the input');
|
|
1178
|
+
// A GET merges into the query bag.
|
|
1179
|
+
const get = buildRequest({ method: 'GET', url: 'http://localhost/x?q=1' });
|
|
1180
|
+
get.merge({ q: '2' });
|
|
1181
|
+
assert.equal(get.input('q'), '2');
|
|
1182
|
+
});
|