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,340 +1,340 @@
|
|
|
1
|
-
// kernel/tests/validation.test.mjs — $request->validate() → ValidationException
|
|
2
|
-
// → redirect-back (or 422 JSON), the way Illuminate renders it.
|
|
3
|
-
//
|
|
4
|
-
// Unit section: the exception surface + the on-disk error-bag shape.
|
|
5
|
-
// Pipeline section: a compiled `web` route whose handler calls
|
|
6
|
-
// `ctx.request.validate()` and the compiled HTTP kernel renders the failure
|
|
7
|
-
// (302 + flashed errors/_old_input, or 422 JSON for expectsJson). The 1:1 PHP
|
|
8
|
-
// oracle diff lives in the E2E suite; here we assert the compiled behaviour
|
|
9
|
-
// against hand-checked Laravel shapes and, where the payload is written, against
|
|
10
|
-
// the json session bytes.
|
|
11
|
-
import { test } from 'node:test';
|
|
12
|
-
import assert from 'node:assert/strict';
|
|
13
|
-
import {
|
|
14
|
-
ValidationException,
|
|
15
|
-
validationExceptionWithMessages,
|
|
16
|
-
installValidation,
|
|
17
|
-
summarizeValidation,
|
|
18
|
-
sessionErrorBag,
|
|
19
|
-
DEFAULT_DONT_FLASH,
|
|
20
|
-
} from '../validation.mjs';
|
|
21
|
-
import { handleHttp } from '../http.mjs';
|
|
22
|
-
import { normalizeTable } from '../router.mjs';
|
|
23
|
-
import { applyAuthenticatable } from '../auth.mjs';
|
|
24
|
-
import { Model } from '../model.mjs';
|
|
25
|
-
|
|
26
|
-
// The kernel's session save() reads `auth.guard().id()` for the user_id column;
|
|
27
|
-
// a fake authenticatable keeps the auth manager resolvable without a real app.
|
|
28
|
-
class LWFakeUser extends Model {}
|
|
29
|
-
LWFakeUser.table = 'users';
|
|
30
|
-
applyAuthenticatable(LWFakeUser);
|
|
31
|
-
const MODELS = { 'App\\Models\\User': LWFakeUser };
|
|
32
|
-
|
|
33
|
-
let DatabaseSync = null;
|
|
34
|
-
try {
|
|
35
|
-
({ DatabaseSync } = await import('node:sqlite'));
|
|
36
|
-
} catch {
|
|
37
|
-
// Older Node without node:sqlite: the pipeline section skips.
|
|
38
|
-
}
|
|
39
|
-
const noSqlite = DatabaseSync ? false : 'node:sqlite unavailable (needs Node >= 22)';
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// unit — ValidationException surface
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
test('summarizeValidation matches ValidationException::summarize()', () => {
|
|
46
|
-
assert.equal(summarizeValidation({}), 'The given data was invalid.');
|
|
47
|
-
assert.equal(summarizeValidation({ email: ['The email field is required.'] }), 'The email field is required.');
|
|
48
|
-
assert.equal(
|
|
49
|
-
summarizeValidation({ email: ['The email field is required.'], password: ['The password field is required.'] }),
|
|
50
|
-
'The email field is required. (and 1 more error)'
|
|
51
|
-
);
|
|
52
|
-
assert.equal(
|
|
53
|
-
summarizeValidation({
|
|
54
|
-
a: ['one'],
|
|
55
|
-
b: ['two'],
|
|
56
|
-
c: ['three'],
|
|
57
|
-
}),
|
|
58
|
-
'one (and 2 more errors)'
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('ValidationException carries status 422, the error bag, and lwKernel (rethrow marker)', () => {
|
|
63
|
-
const errors = { email: ['The email field is required.'] };
|
|
64
|
-
const e = new ValidationException(errors);
|
|
65
|
-
assert.ok(e instanceof Error);
|
|
66
|
-
assert.equal(e.name, 'ValidationException');
|
|
67
|
-
assert.equal(e.status, 422);
|
|
68
|
-
assert.equal(e.errorBag, 'default');
|
|
69
|
-
assert.equal(e.redirectTo, null);
|
|
70
|
-
assert.equal(e.lwKernel, true);
|
|
71
|
-
assert.deepEqual(e.errors(), errors);
|
|
72
|
-
assert.equal(e.message, 'The email field is required.');
|
|
73
|
-
|
|
74
|
-
const bagged = new ValidationException(errors, { errorBag: 'login', redirectTo: '/back' });
|
|
75
|
-
assert.equal(bagged.errorBag, 'login');
|
|
76
|
-
assert.equal(bagged.redirectTo, '/back');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('sessionErrorBag matches Store::prepareErrorBagForSerialization (json)', () => {
|
|
80
|
-
assert.deepEqual(sessionErrorBag('default', { email: ['nope'] }), {
|
|
81
|
-
default: { format: ':message', messages: { email: ['nope'] } },
|
|
82
|
-
});
|
|
83
|
-
assert.deepEqual(DEFAULT_DONT_FLASH, ['current_password', 'password', 'password_confirmation']);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('validationExceptionWithMessages mirrors ValidationException::withMessages (Arr::wrap + dedupe)', () => {
|
|
87
|
-
// `Arr::wrap()` a scalar, dedupe an array through MessageBag::add (keep-first).
|
|
88
|
-
const e = validationExceptionWithMessages({
|
|
89
|
-
email: 'These credentials do not match our records.',
|
|
90
|
-
password: ['too short', 'too short', 'weak'],
|
|
91
|
-
});
|
|
92
|
-
assert.ok(e instanceof ValidationException);
|
|
93
|
-
assert.deepEqual(e.errors(), {
|
|
94
|
-
email: ['These credentials do not match our records.'],
|
|
95
|
-
password: ['too short', 'weak'],
|
|
96
|
-
});
|
|
97
|
-
assert.equal(e.status, 422);
|
|
98
|
-
assert.equal(e.lwKernel, true);
|
|
99
|
-
assert.equal(e.message, 'These credentials do not match our records. (and 2 more errors)');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// unit — installValidation (Request::validate / validateWithBag)
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
function fakeRequest(data) {
|
|
107
|
-
return { all: () => ({ ...data }) };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
test('installValidation: valid data returns validated()', async () => {
|
|
111
|
-
const req = installValidation(fakeRequest({ email: 'a@b.com', password: 'longenough' }));
|
|
112
|
-
const validated = await req.validate({ email: 'required|email', password: 'required|min:8' });
|
|
113
|
-
assert.deepEqual(validated, { email: 'a@b.com', password: 'longenough' });
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('installValidation: invalid data throws ValidationException with errors', async () => {
|
|
117
|
-
const req = installValidation(fakeRequest({ email: 'nope' }));
|
|
118
|
-
await assert.rejects(
|
|
119
|
-
() => req.validate({ email: 'required|email' }),
|
|
120
|
-
(e) => {
|
|
121
|
-
assert.ok(e instanceof ValidationException);
|
|
122
|
-
assert.deepEqual(Object.keys(e.errors()), ['email']);
|
|
123
|
-
assert.match(e.errors().email[0], /valid email/);
|
|
124
|
-
assert.equal(e.errorBag, 'default');
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('installValidation: validateWithBag tags the named error bag', async () => {
|
|
131
|
-
const req = installValidation(fakeRequest({ email: '' }));
|
|
132
|
-
await assert.rejects(
|
|
133
|
-
() => req.validateWithBag('login', { email: 'required' }),
|
|
134
|
-
(e) => {
|
|
135
|
-
assert.equal(e.errorBag, 'login');
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
// pipeline — compiled kernel rendering
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
const KEY = Buffer.alloc(32, 7).toString('base64');
|
|
146
|
-
|
|
147
|
-
function baseConfig(overrides = {}) {
|
|
148
|
-
return {
|
|
149
|
-
'app.key': 'base64:' + KEY,
|
|
150
|
-
'session.driver': 'database',
|
|
151
|
-
'session.cookie': 'laravel_session',
|
|
152
|
-
'session.lifetime': 120,
|
|
153
|
-
'session.expire_on_close': false,
|
|
154
|
-
'session.serialization': 'json',
|
|
155
|
-
'session.table': 'sessions',
|
|
156
|
-
'session.path': '/',
|
|
157
|
-
'session.domain': null,
|
|
158
|
-
'session.secure': null,
|
|
159
|
-
'session.http_only': true,
|
|
160
|
-
'session.same_site': 'lax',
|
|
161
|
-
'session.encrypt': false,
|
|
162
|
-
'csrf.except': ['probe/validate'],
|
|
163
|
-
'cookies.except': [],
|
|
164
|
-
'auth.defaults.guard': 'web',
|
|
165
|
-
'auth.guards.web': { driver: 'session', provider: 'users' },
|
|
166
|
-
'auth.providers.users': { driver: 'eloquent', model: 'App\\Models\\User' },
|
|
167
|
-
...overrides,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function d1(sqlite) {
|
|
172
|
-
return {
|
|
173
|
-
prepare(sql) {
|
|
174
|
-
let params = [];
|
|
175
|
-
return {
|
|
176
|
-
bind(...p) {
|
|
177
|
-
params = p;
|
|
178
|
-
return this;
|
|
179
|
-
},
|
|
180
|
-
async all() {
|
|
181
|
-
const st = sqlite.prepare(sql);
|
|
182
|
-
const rows = params.length ? st.all(...params) : st.all();
|
|
183
|
-
return { results: rows.map((r) => ({ ...r })) };
|
|
184
|
-
},
|
|
185
|
-
async first() {
|
|
186
|
-
const st = sqlite.prepare(sql);
|
|
187
|
-
const row = params.length ? st.get(...params) : st.get();
|
|
188
|
-
return row ? { ...row } : null;
|
|
189
|
-
},
|
|
190
|
-
async run() {
|
|
191
|
-
const st = sqlite.prepare(sql);
|
|
192
|
-
const info = params.length ? st.run(...params) : st.run();
|
|
193
|
-
return {
|
|
194
|
-
success: true,
|
|
195
|
-
meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) },
|
|
196
|
-
};
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
},
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function makeDb() {
|
|
204
|
-
const sqlite = new DatabaseSync(':memory:');
|
|
205
|
-
sqlite.exec(`
|
|
206
|
-
CREATE TABLE sessions (
|
|
207
|
-
id TEXT PRIMARY KEY,
|
|
208
|
-
user_id INTEGER NULL,
|
|
209
|
-
ip_address TEXT NULL,
|
|
210
|
-
user_agent TEXT NULL,
|
|
211
|
-
payload TEXT NOT NULL,
|
|
212
|
-
last_activity INTEGER NOT NULL
|
|
213
|
-
);
|
|
214
|
-
`);
|
|
215
|
-
return { sqlite, db: d1(sqlite) };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const RULES = { email: 'required|email', password: 'required|min:8' };
|
|
219
|
-
|
|
220
|
-
const HANDLERS = {
|
|
221
|
-
'POST /probe/validate': async (ctx) => {
|
|
222
|
-
const validated = await ctx.request.validate(RULES);
|
|
223
|
-
return {
|
|
224
|
-
status: 200,
|
|
225
|
-
headers: { 'content-type': 'application/json' },
|
|
226
|
-
body: JSON.stringify(validated),
|
|
227
|
-
};
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const ROUTES = [{ uri: '/probe/validate', methods: ['POST'], middleware: ['web'] }];
|
|
232
|
-
|
|
233
|
-
async function post(config, { accept = 'text/html', referer = 'http://localhost/form', form } = {}) {
|
|
234
|
-
const { sqlite, db } = makeDb();
|
|
235
|
-
const headers = { host: 'localhost', accept, referer, 'user-agent': 'Symfony' };
|
|
236
|
-
const body = new URLSearchParams(form).toString();
|
|
237
|
-
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
238
|
-
const res = await handleHttp(
|
|
239
|
-
{ method: 'POST', url: 'http://localhost/probe/validate', headers, body },
|
|
240
|
-
{
|
|
241
|
-
table: normalizeTable(ROUTES),
|
|
242
|
-
handlers: HANDLERS,
|
|
243
|
-
config,
|
|
244
|
-
env: { DB: db },
|
|
245
|
-
db,
|
|
246
|
-
models: MODELS,
|
|
247
|
-
clock: () => 1792336827,
|
|
248
|
-
randomString: (n) => 'A'.repeat(n),
|
|
249
|
-
clientIp: '127.0.0.1',
|
|
250
|
-
}
|
|
251
|
-
);
|
|
252
|
-
const rows = sqlite.prepare('SELECT * FROM sessions').all().map((r) => ({ ...r }));
|
|
253
|
-
return { res, rows };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function payloadOf(rows) {
|
|
257
|
-
assert.equal(rows.length, 1, 'expected exactly one session row');
|
|
258
|
-
// The database session handler stores the payload base64-encoded (Laravel
|
|
259
|
-
// DatabaseSessionHandler::write does `base64_encode($data)`).
|
|
260
|
-
return JSON.parse(Buffer.from(String(rows[0].payload), 'base64').toString('utf8'));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
test('pipeline: failed validation redirects back and flashes errors + old input', { skip: noSqlite }, async () => {
|
|
264
|
-
const { res, rows } = await post(baseConfig(), {
|
|
265
|
-
form: {
|
|
266
|
-
email: 'not-an-email',
|
|
267
|
-
password: 'short',
|
|
268
|
-
password_confirmation: 'short',
|
|
269
|
-
name: 'Ada',
|
|
270
|
-
_token: 'whatever',
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
assert.equal(res.status, 302);
|
|
275
|
-
assert.equal(res.headers.location, 'http://localhost/form');
|
|
276
|
-
|
|
277
|
-
const payload = payloadOf(rows);
|
|
278
|
-
assert.deepEqual(payload.errors, {
|
|
279
|
-
default: {
|
|
280
|
-
format: ':message',
|
|
281
|
-
messages: {
|
|
282
|
-
email: ['The email field must be a valid email address.'],
|
|
283
|
-
password: ['The password field must be at least 8 characters.'],
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
assert.deepEqual(payload._old_input, {
|
|
288
|
-
email: 'not-an-email',
|
|
289
|
-
name: 'Ada',
|
|
290
|
-
_token: 'whatever',
|
|
291
|
-
});
|
|
292
|
-
assert.ok(!('password' in payload._old_input), 'password must not be flashed');
|
|
293
|
-
assert.ok(!('password_confirmation' in payload._old_input), 'password_confirmation must not be flashed');
|
|
294
|
-
assert.ok(payload._flash.old.includes('errors'));
|
|
295
|
-
assert.ok(payload._flash.old.includes('_old_input'));
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test('pipeline: failed validation falls back to the session previous URL when no Referer', { skip: noSqlite }, async () => {
|
|
299
|
-
const { res } = await post(baseConfig(), { referer: '', form: { email: '', _token: 'x' } });
|
|
300
|
-
assert.equal(res.status, 302);
|
|
301
|
-
assert.equal(res.headers.location, 'http://localhost');
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test('pipeline: expectsJson validation failure is a 422 JSON body with no flash', { skip: noSqlite }, async () => {
|
|
305
|
-
const { res, rows } = await post(baseConfig(), {
|
|
306
|
-
accept: 'application/json',
|
|
307
|
-
form: { email: '', password: 'short', _token: 'x' },
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
assert.equal(res.status, 422);
|
|
311
|
-
assert.equal(res.headers['content-type'], 'application/json');
|
|
312
|
-
const parsed = JSON.parse(res.body);
|
|
313
|
-
assert.equal(parsed.message, 'The email field is required. (and 1 more error)');
|
|
314
|
-
assert.deepEqual(parsed.errors, {
|
|
315
|
-
email: ['The email field is required.'],
|
|
316
|
-
password: ['The password field must be at least 8 characters.'],
|
|
317
|
-
});
|
|
318
|
-
const payload = payloadOf(rows);
|
|
319
|
-
assert.ok(!('errors' in payload), 'json validation failures must not flash an error bag');
|
|
320
|
-
assert.ok(!('_old_input' in payload), 'json validation failures must not flash old input');
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
test('pipeline: valid data reaches the controller and is not flashed', { skip: noSqlite }, async () => {
|
|
324
|
-
const { res, rows } = await post(baseConfig(), {
|
|
325
|
-
accept: 'application/json',
|
|
326
|
-
form: { email: 'a@b.com', password: 'longenough', _token: 'x' },
|
|
327
|
-
});
|
|
328
|
-
assert.equal(res.status, 200);
|
|
329
|
-
assert.deepEqual(JSON.parse(res.body), { email: 'a@b.com', password: 'longenough' });
|
|
330
|
-
const payload = payloadOf(rows);
|
|
331
|
-
assert.ok(!('errors' in payload));
|
|
332
|
-
assert.ok(!('_old_input' in payload));
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
test('pipeline: php session serialization fails loud for validation redirects', { skip: noSqlite }, async () => {
|
|
336
|
-
await assert.rejects(
|
|
337
|
-
() => post(baseConfig({ 'session.serialization': 'php' }), { form: { email: '', _token: 'x' } }),
|
|
338
|
-
/session\.serialization=json/
|
|
339
|
-
);
|
|
340
|
-
});
|
|
1
|
+
// kernel/tests/validation.test.mjs — $request->validate() → ValidationException
|
|
2
|
+
// → redirect-back (or 422 JSON), the way Illuminate renders it.
|
|
3
|
+
//
|
|
4
|
+
// Unit section: the exception surface + the on-disk error-bag shape.
|
|
5
|
+
// Pipeline section: a compiled `web` route whose handler calls
|
|
6
|
+
// `ctx.request.validate()` and the compiled HTTP kernel renders the failure
|
|
7
|
+
// (302 + flashed errors/_old_input, or 422 JSON for expectsJson). The 1:1 PHP
|
|
8
|
+
// oracle diff lives in the E2E suite; here we assert the compiled behaviour
|
|
9
|
+
// against hand-checked Laravel shapes and, where the payload is written, against
|
|
10
|
+
// the json session bytes.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import {
|
|
14
|
+
ValidationException,
|
|
15
|
+
validationExceptionWithMessages,
|
|
16
|
+
installValidation,
|
|
17
|
+
summarizeValidation,
|
|
18
|
+
sessionErrorBag,
|
|
19
|
+
DEFAULT_DONT_FLASH,
|
|
20
|
+
} from '../validation.mjs';
|
|
21
|
+
import { handleHttp } from '../http.mjs';
|
|
22
|
+
import { normalizeTable } from '../router.mjs';
|
|
23
|
+
import { applyAuthenticatable } from '../auth.mjs';
|
|
24
|
+
import { Model } from '../model.mjs';
|
|
25
|
+
|
|
26
|
+
// The kernel's session save() reads `auth.guard().id()` for the user_id column;
|
|
27
|
+
// a fake authenticatable keeps the auth manager resolvable without a real app.
|
|
28
|
+
class LWFakeUser extends Model {}
|
|
29
|
+
LWFakeUser.table = 'users';
|
|
30
|
+
applyAuthenticatable(LWFakeUser);
|
|
31
|
+
const MODELS = { 'App\\Models\\User': LWFakeUser };
|
|
32
|
+
|
|
33
|
+
let DatabaseSync = null;
|
|
34
|
+
try {
|
|
35
|
+
({ DatabaseSync } = await import('node:sqlite'));
|
|
36
|
+
} catch {
|
|
37
|
+
// Older Node without node:sqlite: the pipeline section skips.
|
|
38
|
+
}
|
|
39
|
+
const noSqlite = DatabaseSync ? false : 'node:sqlite unavailable (needs Node >= 22)';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// unit — ValidationException surface
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('summarizeValidation matches ValidationException::summarize()', () => {
|
|
46
|
+
assert.equal(summarizeValidation({}), 'The given data was invalid.');
|
|
47
|
+
assert.equal(summarizeValidation({ email: ['The email field is required.'] }), 'The email field is required.');
|
|
48
|
+
assert.equal(
|
|
49
|
+
summarizeValidation({ email: ['The email field is required.'], password: ['The password field is required.'] }),
|
|
50
|
+
'The email field is required. (and 1 more error)'
|
|
51
|
+
);
|
|
52
|
+
assert.equal(
|
|
53
|
+
summarizeValidation({
|
|
54
|
+
a: ['one'],
|
|
55
|
+
b: ['two'],
|
|
56
|
+
c: ['three'],
|
|
57
|
+
}),
|
|
58
|
+
'one (and 2 more errors)'
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('ValidationException carries status 422, the error bag, and lwKernel (rethrow marker)', () => {
|
|
63
|
+
const errors = { email: ['The email field is required.'] };
|
|
64
|
+
const e = new ValidationException(errors);
|
|
65
|
+
assert.ok(e instanceof Error);
|
|
66
|
+
assert.equal(e.name, 'ValidationException');
|
|
67
|
+
assert.equal(e.status, 422);
|
|
68
|
+
assert.equal(e.errorBag, 'default');
|
|
69
|
+
assert.equal(e.redirectTo, null);
|
|
70
|
+
assert.equal(e.lwKernel, true);
|
|
71
|
+
assert.deepEqual(e.errors(), errors);
|
|
72
|
+
assert.equal(e.message, 'The email field is required.');
|
|
73
|
+
|
|
74
|
+
const bagged = new ValidationException(errors, { errorBag: 'login', redirectTo: '/back' });
|
|
75
|
+
assert.equal(bagged.errorBag, 'login');
|
|
76
|
+
assert.equal(bagged.redirectTo, '/back');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('sessionErrorBag matches Store::prepareErrorBagForSerialization (json)', () => {
|
|
80
|
+
assert.deepEqual(sessionErrorBag('default', { email: ['nope'] }), {
|
|
81
|
+
default: { format: ':message', messages: { email: ['nope'] } },
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(DEFAULT_DONT_FLASH, ['current_password', 'password', 'password_confirmation']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('validationExceptionWithMessages mirrors ValidationException::withMessages (Arr::wrap + dedupe)', () => {
|
|
87
|
+
// `Arr::wrap()` a scalar, dedupe an array through MessageBag::add (keep-first).
|
|
88
|
+
const e = validationExceptionWithMessages({
|
|
89
|
+
email: 'These credentials do not match our records.',
|
|
90
|
+
password: ['too short', 'too short', 'weak'],
|
|
91
|
+
});
|
|
92
|
+
assert.ok(e instanceof ValidationException);
|
|
93
|
+
assert.deepEqual(e.errors(), {
|
|
94
|
+
email: ['These credentials do not match our records.'],
|
|
95
|
+
password: ['too short', 'weak'],
|
|
96
|
+
});
|
|
97
|
+
assert.equal(e.status, 422);
|
|
98
|
+
assert.equal(e.lwKernel, true);
|
|
99
|
+
assert.equal(e.message, 'These credentials do not match our records. (and 2 more errors)');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// unit — installValidation (Request::validate / validateWithBag)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function fakeRequest(data) {
|
|
107
|
+
return { all: () => ({ ...data }) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test('installValidation: valid data returns validated()', async () => {
|
|
111
|
+
const req = installValidation(fakeRequest({ email: 'a@b.com', password: 'longenough' }));
|
|
112
|
+
const validated = await req.validate({ email: 'required|email', password: 'required|min:8' });
|
|
113
|
+
assert.deepEqual(validated, { email: 'a@b.com', password: 'longenough' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('installValidation: invalid data throws ValidationException with errors', async () => {
|
|
117
|
+
const req = installValidation(fakeRequest({ email: 'nope' }));
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
() => req.validate({ email: 'required|email' }),
|
|
120
|
+
(e) => {
|
|
121
|
+
assert.ok(e instanceof ValidationException);
|
|
122
|
+
assert.deepEqual(Object.keys(e.errors()), ['email']);
|
|
123
|
+
assert.match(e.errors().email[0], /valid email/);
|
|
124
|
+
assert.equal(e.errorBag, 'default');
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('installValidation: validateWithBag tags the named error bag', async () => {
|
|
131
|
+
const req = installValidation(fakeRequest({ email: '' }));
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => req.validateWithBag('login', { email: 'required' }),
|
|
134
|
+
(e) => {
|
|
135
|
+
assert.equal(e.errorBag, 'login');
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// pipeline — compiled kernel rendering
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const KEY = Buffer.alloc(32, 7).toString('base64');
|
|
146
|
+
|
|
147
|
+
function baseConfig(overrides = {}) {
|
|
148
|
+
return {
|
|
149
|
+
'app.key': 'base64:' + KEY,
|
|
150
|
+
'session.driver': 'database',
|
|
151
|
+
'session.cookie': 'laravel_session',
|
|
152
|
+
'session.lifetime': 120,
|
|
153
|
+
'session.expire_on_close': false,
|
|
154
|
+
'session.serialization': 'json',
|
|
155
|
+
'session.table': 'sessions',
|
|
156
|
+
'session.path': '/',
|
|
157
|
+
'session.domain': null,
|
|
158
|
+
'session.secure': null,
|
|
159
|
+
'session.http_only': true,
|
|
160
|
+
'session.same_site': 'lax',
|
|
161
|
+
'session.encrypt': false,
|
|
162
|
+
'csrf.except': ['probe/validate'],
|
|
163
|
+
'cookies.except': [],
|
|
164
|
+
'auth.defaults.guard': 'web',
|
|
165
|
+
'auth.guards.web': { driver: 'session', provider: 'users' },
|
|
166
|
+
'auth.providers.users': { driver: 'eloquent', model: 'App\\Models\\User' },
|
|
167
|
+
...overrides,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function d1(sqlite) {
|
|
172
|
+
return {
|
|
173
|
+
prepare(sql) {
|
|
174
|
+
let params = [];
|
|
175
|
+
return {
|
|
176
|
+
bind(...p) {
|
|
177
|
+
params = p;
|
|
178
|
+
return this;
|
|
179
|
+
},
|
|
180
|
+
async all() {
|
|
181
|
+
const st = sqlite.prepare(sql);
|
|
182
|
+
const rows = params.length ? st.all(...params) : st.all();
|
|
183
|
+
return { results: rows.map((r) => ({ ...r })) };
|
|
184
|
+
},
|
|
185
|
+
async first() {
|
|
186
|
+
const st = sqlite.prepare(sql);
|
|
187
|
+
const row = params.length ? st.get(...params) : st.get();
|
|
188
|
+
return row ? { ...row } : null;
|
|
189
|
+
},
|
|
190
|
+
async run() {
|
|
191
|
+
const st = sqlite.prepare(sql);
|
|
192
|
+
const info = params.length ? st.run(...params) : st.run();
|
|
193
|
+
return {
|
|
194
|
+
success: true,
|
|
195
|
+
meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) },
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function makeDb() {
|
|
204
|
+
const sqlite = new DatabaseSync(':memory:');
|
|
205
|
+
sqlite.exec(`
|
|
206
|
+
CREATE TABLE sessions (
|
|
207
|
+
id TEXT PRIMARY KEY,
|
|
208
|
+
user_id INTEGER NULL,
|
|
209
|
+
ip_address TEXT NULL,
|
|
210
|
+
user_agent TEXT NULL,
|
|
211
|
+
payload TEXT NOT NULL,
|
|
212
|
+
last_activity INTEGER NOT NULL
|
|
213
|
+
);
|
|
214
|
+
`);
|
|
215
|
+
return { sqlite, db: d1(sqlite) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const RULES = { email: 'required|email', password: 'required|min:8' };
|
|
219
|
+
|
|
220
|
+
const HANDLERS = {
|
|
221
|
+
'POST /probe/validate': async (ctx) => {
|
|
222
|
+
const validated = await ctx.request.validate(RULES);
|
|
223
|
+
return {
|
|
224
|
+
status: 200,
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
226
|
+
body: JSON.stringify(validated),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const ROUTES = [{ uri: '/probe/validate', methods: ['POST'], middleware: ['web'] }];
|
|
232
|
+
|
|
233
|
+
async function post(config, { accept = 'text/html', referer = 'http://localhost/form', form } = {}) {
|
|
234
|
+
const { sqlite, db } = makeDb();
|
|
235
|
+
const headers = { host: 'localhost', accept, referer, 'user-agent': 'Symfony' };
|
|
236
|
+
const body = new URLSearchParams(form).toString();
|
|
237
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
238
|
+
const res = await handleHttp(
|
|
239
|
+
{ method: 'POST', url: 'http://localhost/probe/validate', headers, body },
|
|
240
|
+
{
|
|
241
|
+
table: normalizeTable(ROUTES),
|
|
242
|
+
handlers: HANDLERS,
|
|
243
|
+
config,
|
|
244
|
+
env: { DB: db },
|
|
245
|
+
db,
|
|
246
|
+
models: MODELS,
|
|
247
|
+
clock: () => 1792336827,
|
|
248
|
+
randomString: (n) => 'A'.repeat(n),
|
|
249
|
+
clientIp: '127.0.0.1',
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
const rows = sqlite.prepare('SELECT * FROM sessions').all().map((r) => ({ ...r }));
|
|
253
|
+
return { res, rows };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function payloadOf(rows) {
|
|
257
|
+
assert.equal(rows.length, 1, 'expected exactly one session row');
|
|
258
|
+
// The database session handler stores the payload base64-encoded (Laravel
|
|
259
|
+
// DatabaseSessionHandler::write does `base64_encode($data)`).
|
|
260
|
+
return JSON.parse(Buffer.from(String(rows[0].payload), 'base64').toString('utf8'));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
test('pipeline: failed validation redirects back and flashes errors + old input', { skip: noSqlite }, async () => {
|
|
264
|
+
const { res, rows } = await post(baseConfig(), {
|
|
265
|
+
form: {
|
|
266
|
+
email: 'not-an-email',
|
|
267
|
+
password: 'short',
|
|
268
|
+
password_confirmation: 'short',
|
|
269
|
+
name: 'Ada',
|
|
270
|
+
_token: 'whatever',
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
assert.equal(res.status, 302);
|
|
275
|
+
assert.equal(res.headers.location, 'http://localhost/form');
|
|
276
|
+
|
|
277
|
+
const payload = payloadOf(rows);
|
|
278
|
+
assert.deepEqual(payload.errors, {
|
|
279
|
+
default: {
|
|
280
|
+
format: ':message',
|
|
281
|
+
messages: {
|
|
282
|
+
email: ['The email field must be a valid email address.'],
|
|
283
|
+
password: ['The password field must be at least 8 characters.'],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
assert.deepEqual(payload._old_input, {
|
|
288
|
+
email: 'not-an-email',
|
|
289
|
+
name: 'Ada',
|
|
290
|
+
_token: 'whatever',
|
|
291
|
+
});
|
|
292
|
+
assert.ok(!('password' in payload._old_input), 'password must not be flashed');
|
|
293
|
+
assert.ok(!('password_confirmation' in payload._old_input), 'password_confirmation must not be flashed');
|
|
294
|
+
assert.ok(payload._flash.old.includes('errors'));
|
|
295
|
+
assert.ok(payload._flash.old.includes('_old_input'));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('pipeline: failed validation falls back to the session previous URL when no Referer', { skip: noSqlite }, async () => {
|
|
299
|
+
const { res } = await post(baseConfig(), { referer: '', form: { email: '', _token: 'x' } });
|
|
300
|
+
assert.equal(res.status, 302);
|
|
301
|
+
assert.equal(res.headers.location, 'http://localhost');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('pipeline: expectsJson validation failure is a 422 JSON body with no flash', { skip: noSqlite }, async () => {
|
|
305
|
+
const { res, rows } = await post(baseConfig(), {
|
|
306
|
+
accept: 'application/json',
|
|
307
|
+
form: { email: '', password: 'short', _token: 'x' },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
assert.equal(res.status, 422);
|
|
311
|
+
assert.equal(res.headers['content-type'], 'application/json');
|
|
312
|
+
const parsed = JSON.parse(res.body);
|
|
313
|
+
assert.equal(parsed.message, 'The email field is required. (and 1 more error)');
|
|
314
|
+
assert.deepEqual(parsed.errors, {
|
|
315
|
+
email: ['The email field is required.'],
|
|
316
|
+
password: ['The password field must be at least 8 characters.'],
|
|
317
|
+
});
|
|
318
|
+
const payload = payloadOf(rows);
|
|
319
|
+
assert.ok(!('errors' in payload), 'json validation failures must not flash an error bag');
|
|
320
|
+
assert.ok(!('_old_input' in payload), 'json validation failures must not flash old input');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('pipeline: valid data reaches the controller and is not flashed', { skip: noSqlite }, async () => {
|
|
324
|
+
const { res, rows } = await post(baseConfig(), {
|
|
325
|
+
accept: 'application/json',
|
|
326
|
+
form: { email: 'a@b.com', password: 'longenough', _token: 'x' },
|
|
327
|
+
});
|
|
328
|
+
assert.equal(res.status, 200);
|
|
329
|
+
assert.deepEqual(JSON.parse(res.body), { email: 'a@b.com', password: 'longenough' });
|
|
330
|
+
const payload = payloadOf(rows);
|
|
331
|
+
assert.ok(!('errors' in payload));
|
|
332
|
+
assert.ok(!('_old_input' in payload));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('pipeline: php session serialization fails loud for validation redirects', { skip: noSqlite }, async () => {
|
|
336
|
+
await assert.rejects(
|
|
337
|
+
() => post(baseConfig({ 'session.serialization': 'php' }), { form: { email: '', _token: 'x' } }),
|
|
338
|
+
/session\.serialization=json/
|
|
339
|
+
);
|
|
340
|
+
});
|