javascript-solid-server 0.0.175 → 0.0.177
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/.claude/scheduled_tasks.lock +1 -0
- package/README.md +1 -0
- package/docs/lws.md +84 -0
- package/package.json +1 -1
- package/src/auth/lws-cid.js +679 -0
- package/src/auth/token.js +12 -1
- package/src/idp/credentials.js +176 -37
- package/src/idp/index.js +33 -1
- package/src/idp/views.js +188 -0
- package/test/idp-delete-account.test.js +301 -0
- package/test/lws-cid.test.js +705 -0
|
@@ -269,6 +269,307 @@ describe('DELETE /idp/account — self-delete', () => {
|
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
describe('GET/POST /idp/account/delete — HTML form (#392)', () => {
|
|
273
|
+
let server;
|
|
274
|
+
let baseUrl;
|
|
275
|
+
let originalDataRoot;
|
|
276
|
+
const DATA_DIR = './test-data-delete-form';
|
|
277
|
+
|
|
278
|
+
before(async () => {
|
|
279
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
280
|
+
await fs.remove(DATA_DIR);
|
|
281
|
+
await fs.ensureDir(DATA_DIR);
|
|
282
|
+
const port = await getAvailablePort();
|
|
283
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
284
|
+
server = createServer({
|
|
285
|
+
logger: false,
|
|
286
|
+
root: DATA_DIR,
|
|
287
|
+
idp: true,
|
|
288
|
+
idpIssuer: baseUrl,
|
|
289
|
+
forceCloseConnections: true,
|
|
290
|
+
});
|
|
291
|
+
await server.listen({ port, host: TEST_HOST });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
after(async () => {
|
|
295
|
+
await server.close();
|
|
296
|
+
await fs.remove(DATA_DIR);
|
|
297
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
298
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('GET renders the form HTML', async () => {
|
|
302
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
303
|
+
assert.strictEqual(res.status, 200);
|
|
304
|
+
const ct = res.headers.get('content-type') || '';
|
|
305
|
+
assert.match(ct, /text\/html/);
|
|
306
|
+
const html = await res.text();
|
|
307
|
+
assert.match(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
|
|
308
|
+
assert.match(html, /name="username"/);
|
|
309
|
+
assert.match(html, /name="currentPassword"/);
|
|
310
|
+
assert.match(html, /name="confirmUsername"/);
|
|
311
|
+
// Form's checkbox is `keepData` (inverse of the JSON endpoint's
|
|
312
|
+
// purgeData) so the form's default is purge-on for the leaving-user UX.
|
|
313
|
+
assert.match(html, /name="keepData"/);
|
|
314
|
+
assert.match(html, /Delete my account permanently/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('GET sets anti-clickjacking + no-store headers', async () => {
|
|
318
|
+
// Destructive-action page (form for account deletion) — must not
|
|
319
|
+
// be cacheable or embeddable in an iframe.
|
|
320
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
321
|
+
assert.strictEqual(res.status, 200);
|
|
322
|
+
assert.match(res.headers.get('cache-control') || '', /no-store/i);
|
|
323
|
+
assert.strictEqual(res.headers.get('x-frame-options'), 'DENY');
|
|
324
|
+
assert.match(res.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('POST sets the same security headers on success and error responses', async () => {
|
|
328
|
+
// Error response: missing fields
|
|
329
|
+
const errRes = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
332
|
+
body: new URLSearchParams({ username: 'x' }),
|
|
333
|
+
});
|
|
334
|
+
assert.match(errRes.headers.get('cache-control') || '', /no-store/i);
|
|
335
|
+
assert.strictEqual(errRes.headers.get('x-frame-options'), 'DENY');
|
|
336
|
+
assert.match(errRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
337
|
+
|
|
338
|
+
// Success response: full delete flow
|
|
339
|
+
const id = `lyle${Date.now()}`;
|
|
340
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
341
|
+
const okRes = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
344
|
+
body: new URLSearchParams({
|
|
345
|
+
username: id,
|
|
346
|
+
currentPassword: 'password123',
|
|
347
|
+
confirmUsername: id,
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
assert.strictEqual(okRes.status, 200);
|
|
351
|
+
assert.match(okRes.headers.get('cache-control') || '', /no-store/i);
|
|
352
|
+
assert.strictEqual(okRes.headers.get('x-frame-options'), 'DENY');
|
|
353
|
+
assert.match(okRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('POST happy path: deletes account AND wipes pod data by default; login fails after', async () => {
|
|
357
|
+
// Form's default is purge-on (the user is leaving — wipe everything).
|
|
358
|
+
// The JSON DELETE endpoint keeps purge-off as default (matches CLI for
|
|
359
|
+
// programmatic / operator use); the form diverges deliberately for the
|
|
360
|
+
// leaving-user UX.
|
|
361
|
+
const id = `harry${Date.now()}`;
|
|
362
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
363
|
+
const podPath = path.join(DATA_DIR, id);
|
|
364
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
365
|
+
'pod tree should exist before deletion');
|
|
366
|
+
|
|
367
|
+
const formBody = new URLSearchParams({
|
|
368
|
+
username: id,
|
|
369
|
+
currentPassword: 'password123',
|
|
370
|
+
confirmUsername: id,
|
|
371
|
+
// No keepData — defaults to purge-on
|
|
372
|
+
});
|
|
373
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
376
|
+
body: formBody,
|
|
377
|
+
});
|
|
378
|
+
assert.strictEqual(res.status, 200);
|
|
379
|
+
const html = await res.text();
|
|
380
|
+
assert.match(html, /Account deleted/);
|
|
381
|
+
|
|
382
|
+
// Login now fails
|
|
383
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers: { 'Content-Type': 'application/json' },
|
|
386
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
387
|
+
});
|
|
388
|
+
assert.strictEqual(reLogin.status, 401);
|
|
389
|
+
|
|
390
|
+
// Pod data also wiped (default behavior on the form)
|
|
391
|
+
assert.strictEqual(await fs.pathExists(podPath), false,
|
|
392
|
+
'pod data should be wiped by default on the form path');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('POST with keepData=on opts out of the purge, account still deleted', async () => {
|
|
396
|
+
const id = `iris${Date.now()}`;
|
|
397
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
398
|
+
const podPath = path.join(DATA_DIR, id);
|
|
399
|
+
assert.strictEqual(await fs.pathExists(podPath), true);
|
|
400
|
+
|
|
401
|
+
const formBody = new URLSearchParams({
|
|
402
|
+
username: id,
|
|
403
|
+
currentPassword: 'password123',
|
|
404
|
+
confirmUsername: id,
|
|
405
|
+
keepData: 'on',
|
|
406
|
+
});
|
|
407
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
410
|
+
body: formBody,
|
|
411
|
+
});
|
|
412
|
+
assert.strictEqual(res.status, 200);
|
|
413
|
+
|
|
414
|
+
// Account gone
|
|
415
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
419
|
+
});
|
|
420
|
+
assert.strictEqual(reLogin.status, 401);
|
|
421
|
+
|
|
422
|
+
// Pod data preserved (the user opted to keep it)
|
|
423
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
424
|
+
'keepData=on should preserve pod data even though account is deleted');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('POST with mismatched confirmUsername renders form with error, account untouched', async () => {
|
|
428
|
+
const id = `jack${Date.now()}`;
|
|
429
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
430
|
+
|
|
431
|
+
const formBody = new URLSearchParams({
|
|
432
|
+
username: id,
|
|
433
|
+
currentPassword: 'password123',
|
|
434
|
+
confirmUsername: 'totally-different',
|
|
435
|
+
});
|
|
436
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
439
|
+
body: formBody,
|
|
440
|
+
});
|
|
441
|
+
assert.strictEqual(res.status, 200);
|
|
442
|
+
const html = await res.text();
|
|
443
|
+
assert.match(html, /Confirmation does not match/);
|
|
444
|
+
// Username pre-filled in the form for retry
|
|
445
|
+
assert.match(html, new RegExp(`value="${id}"`));
|
|
446
|
+
|
|
447
|
+
// Account intact
|
|
448
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: { 'Content-Type': 'application/json' },
|
|
451
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
452
|
+
});
|
|
453
|
+
assert.strictEqual(login.status, 200);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('POST with wrong password renders form with error, account untouched', async () => {
|
|
457
|
+
const id = `kelly${Date.now()}`;
|
|
458
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
459
|
+
|
|
460
|
+
const formBody = new URLSearchParams({
|
|
461
|
+
username: id,
|
|
462
|
+
currentPassword: 'wrong',
|
|
463
|
+
confirmUsername: id,
|
|
464
|
+
});
|
|
465
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
468
|
+
body: formBody,
|
|
469
|
+
});
|
|
470
|
+
assert.strictEqual(res.status, 200);
|
|
471
|
+
const html = await res.text();
|
|
472
|
+
assert.match(html, /incorrect/i);
|
|
473
|
+
|
|
474
|
+
// Account intact
|
|
475
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { 'Content-Type': 'application/json' },
|
|
478
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
479
|
+
});
|
|
480
|
+
assert.strictEqual(login.status, 200);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('POST with missing fields renders form with error', async () => {
|
|
484
|
+
const formBody = new URLSearchParams({
|
|
485
|
+
username: 'someone',
|
|
486
|
+
// currentPassword and confirmUsername omitted
|
|
487
|
+
});
|
|
488
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
491
|
+
body: formBody,
|
|
492
|
+
});
|
|
493
|
+
assert.strictEqual(res.status, 200);
|
|
494
|
+
const html = await res.text();
|
|
495
|
+
assert.match(html, /required/i);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('GET/POST /idp/account/delete — single-user mode renders disabled message', () => {
|
|
500
|
+
let server;
|
|
501
|
+
let baseUrl;
|
|
502
|
+
let originalDataRoot;
|
|
503
|
+
let originalPassword;
|
|
504
|
+
const DATA_DIR = './test-data-delete-form-single';
|
|
505
|
+
|
|
506
|
+
before(async () => {
|
|
507
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
508
|
+
originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
|
|
509
|
+
process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
|
|
510
|
+
await fs.remove(DATA_DIR);
|
|
511
|
+
await fs.ensureDir(DATA_DIR);
|
|
512
|
+
const port = await getAvailablePort();
|
|
513
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
514
|
+
server = createServer({
|
|
515
|
+
logger: false,
|
|
516
|
+
root: DATA_DIR,
|
|
517
|
+
idp: true,
|
|
518
|
+
idpIssuer: baseUrl,
|
|
519
|
+
singleUser: true,
|
|
520
|
+
singleUserName: 'me',
|
|
521
|
+
singleUserPassword: 'singletest',
|
|
522
|
+
forceCloseConnections: true,
|
|
523
|
+
});
|
|
524
|
+
await server.listen({ port, host: TEST_HOST });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
after(async () => {
|
|
528
|
+
await server.close();
|
|
529
|
+
await fs.remove(DATA_DIR);
|
|
530
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
531
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
532
|
+
if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
|
|
533
|
+
else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('GET returns 403 with the disabled-message HTML', async () => {
|
|
537
|
+
// 403 keeps single-user disabled routes consistent: /idp/register,
|
|
538
|
+
// the JSON DELETE /idp/account, and this GET all 403 in single-user.
|
|
539
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
540
|
+
assert.strictEqual(res.status, 403);
|
|
541
|
+
const html = await res.text();
|
|
542
|
+
assert.match(html, /single-user mode/i);
|
|
543
|
+
assert.match(html, /jss account delete/);
|
|
544
|
+
// No form
|
|
545
|
+
assert.doesNotMatch(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('POST returns 403 with disabled-message HTML — does not delete', async () => {
|
|
549
|
+
const formBody = new URLSearchParams({
|
|
550
|
+
username: 'me',
|
|
551
|
+
currentPassword: 'singletest',
|
|
552
|
+
confirmUsername: 'me',
|
|
553
|
+
});
|
|
554
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
557
|
+
body: formBody,
|
|
558
|
+
});
|
|
559
|
+
assert.strictEqual(res.status, 403);
|
|
560
|
+
const html = await res.text();
|
|
561
|
+
assert.match(html, /single-user mode/i);
|
|
562
|
+
|
|
563
|
+
// Account still works
|
|
564
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
565
|
+
method: 'POST',
|
|
566
|
+
headers: { 'Content-Type': 'application/json' },
|
|
567
|
+
body: JSON.stringify({ email: 'me', password: 'singletest' }),
|
|
568
|
+
});
|
|
569
|
+
assert.strictEqual(login.status, 200);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
272
573
|
describe('DELETE /idp/account — single-user mode', () => {
|
|
273
574
|
let server;
|
|
274
575
|
let baseUrl;
|