toiljs 0.0.100 → 0.0.102

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.
@@ -97,10 +97,10 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
97
97
  it(
98
98
  'registers then logs in (full OPRF + ML-DSA + ML-KEM mutual-auth chain)',
99
99
  async () => {
100
- await Auth.register('ada', 'correct horse battery staple', 'ada@example.com');
100
+ await Auth.register('ada', 'correct horse battery stapleA1', 'ada@example.com');
101
101
  // login resolves ONLY if the server's mutual-auth confirmation tag
102
102
  // verified against the client's own shared secret.
103
- const session = await Auth.login('ada', 'correct horse battery staple');
103
+ const session = await Auth.login('ada', 'correct horse battery stapleA1');
104
104
  expect(session.length).toBeGreaterThan(0);
105
105
 
106
106
  // Regression guard: the freshly minted session cookie (captured by the
@@ -129,7 +129,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
129
129
  it(
130
130
  'rejects a wrong password at login',
131
131
  async () => {
132
- await Auth.register('bob', 'hunter2-correct', 'bob@example.com');
132
+ await Auth.register('bob', 'hunter2-correctA1', 'bob@example.com');
133
133
  await expect(Auth.login('bob', 'hunter2-WRONG')).rejects.toThrow(/login failed|request failed/);
134
134
  },
135
135
  60_000,
@@ -146,12 +146,12 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
146
146
  it(
147
147
  'rejects a duplicate registration with a clear (not generic) message',
148
148
  async () => {
149
- await Auth.register('dupe', 'correct horse battery staple', 'dupe@example.com');
149
+ await Auth.register('dupe', 'correct horse battery stapleA1', 'dupe@example.com');
150
150
  // Second registration of the same username must say so explicitly,
151
151
  // not return the generic "request failed". (The username-taken check fires
152
152
  // before the email check, so it stays `already registered` even with the same email.)
153
153
  await expect(
154
- Auth.register('dupe', 'correct horse battery staple', 'dupe@example.com'),
154
+ Auth.register('dupe', 'correct horse battery stapleA1', 'dupe@example.com'),
155
155
  ).rejects.toThrow(/already registered/);
156
156
  },
157
157
  60_000,
@@ -27,9 +27,14 @@ import { __sentEmails, __clearSentEmails } from '../src/devserver/email/index.js
27
27
  import {
28
28
  Auth,
29
29
  AuthErrorCode,
30
+ checkPassword,
30
31
  EmailInUseError,
31
32
  EmailNotConfirmedError,
33
+ InvalidEmailError,
34
+ InvalidUsernameError,
35
+ isValidEmail,
32
36
  TwoFactorRequiredError,
37
+ WeakPasswordError,
33
38
  } from '../src/client/auth.js';
34
39
  import { DataReader, DataWriter } from '../src/io/codec.js';
35
40
 
@@ -106,6 +111,15 @@ function setConfirmation(on: boolean): void {
106
111
  clearEnvCache();
107
112
  }
108
113
 
114
+ const EMAIL_CONFIRM_ENV = 'AUTH_EMAIL_CONFIRMATION';
115
+
116
+ /** Toggle "send a verify email at register but DO NOT block login" (the decoupled flag). */
117
+ function setEmailConfirmation(on: boolean): void {
118
+ if (on) process.env[EMAIL_CONFIRM_ENV] = 'true';
119
+ else delete process.env[EMAIL_CONFIRM_ENV];
120
+ clearEnvCache();
121
+ }
122
+
109
123
  function hexToBytes(hex: string): Uint8Array {
110
124
  const out = new Uint8Array(hex.length / 2);
111
125
  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
@@ -157,6 +171,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
157
171
  __resetRatelimitForTests();
158
172
  __clearSentEmails();
159
173
  setConfirmation(false); // default: confirmation off unless a case opts in
174
+ setEmailConfirmation(false);
160
175
  mod = loadModule();
161
176
  const shim = installFetchShim(mod);
162
177
  restoreFetch = shim.restore;
@@ -166,13 +181,14 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
166
181
  afterEach(() => {
167
182
  restoreFetch();
168
183
  setConfirmation(false);
184
+ setEmailConfirmation(false);
169
185
  });
170
186
 
171
187
  it(
172
188
  'confirmation OFF (default): register auto-confirms, login succeeds, /auth/me returns the user',
173
189
  async () => {
174
- await Auth.register('ada', 'correct horse battery staple', 'ada@example.com');
175
- const session = await Auth.login('ada', 'correct horse battery staple');
190
+ await Auth.register('ada', 'correct horse battery stapleA1', 'ada@example.com');
191
+ const session = await Auth.login('ada', 'correct horse battery stapleA1');
176
192
  expect(session.length).toBeGreaterThan(0);
177
193
 
178
194
  // The controller's `@auth`-gated /auth/me returns bytes(toilUserId) str(username).
@@ -195,7 +211,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
195
211
  async () => {
196
212
  // grace lives on the victim tenant (realm = normalized Host "victim.com").
197
213
  setHost('victim.com');
198
- await Auth.register('grace', 'pw-grace-correct', 'grace@x.com');
214
+ await Auth.register('grace', 'pw-grace-correctA1', 'grace@x.com');
199
215
  __clearSentEmails();
200
216
  // Attacker triggers reset/request with a Host that routes to the victim
201
217
  // (the edge strip_port -> "victim.com", which is grace's realm, so the
@@ -224,8 +240,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
224
240
  it(
225
241
  'a session minted for one tenant does NOT verify for another (#2 cross-tenant session)',
226
242
  async () => {
227
- await Auth.register('heidi', 'pw-heidi-correct', 'heidi@x.com');
228
- await Auth.login('heidi', 'pw-heidi-correct'); // minted under the shim Host localhost:3000
243
+ await Auth.register('heidi', 'pw-heidi-correctA1', 'heidi@x.com');
244
+ await Auth.login('heidi', 'pw-heidi-correctA1'); // minted under the shim Host localhost:3000
229
245
  const sess = jar.get('toil_sess'); // dev is plain HTTP -> unprefixed cookie
230
246
  expect(sess).toBeTruthy();
231
247
  const meHeaders = (host: string): [string, string][] => [
@@ -256,7 +272,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
256
272
  it(
257
273
  'token bound: a new reset request invalidates the previous token (#5c growth bound)',
258
274
  async () => {
259
- await Auth.register('ivan', 'pw-ivan-correct', 'ivan@x.com');
275
+ await Auth.register('ivan', 'pw-ivan-correctA1', 'ivan@x.com');
260
276
  await Auth.requestPasswordReset('ivan@x.com');
261
277
  const token1 = tokenFromEmail('reset', 'ivan@x.com');
262
278
  __clearSentEmails();
@@ -264,10 +280,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
264
280
  const token2 = tokenFromEmail('reset', 'ivan@x.com');
265
281
  expect(token2).not.toBe(token1);
266
282
  // The superseded token no longer works...
267
- await expect(Auth.resetPassword(token1, 'new-pw-unused')).rejects.toThrow();
283
+ await expect(Auth.resetPassword(token1, 'new-pw-unusedA1')).rejects.toThrow();
268
284
  // ...only the latest does.
269
- await Auth.resetPassword(token2, 'new-pw-ivan');
270
- const session = await Auth.login('ivan', 'new-pw-ivan');
285
+ await Auth.resetPassword(token2, 'new-pw-ivanA1');
286
+ const session = await Auth.login('ivan', 'new-pw-ivanA1');
271
287
  expect(session.length).toBeGreaterThan(0);
272
288
  },
273
289
  60_000,
@@ -277,17 +293,17 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
277
293
  'confirmation ON: login is refused until the emailed token confirms the account',
278
294
  async () => {
279
295
  setConfirmation(true);
280
- await Auth.register('bob', 'hunter2-correct', 'bob@example.com');
296
+ await Auth.register('bob', 'hunter2-correctA1', 'bob@example.com');
281
297
 
282
298
  // A valid credential is not enough: the domain requires a confirmed email.
283
- await expect(Auth.login('bob', 'hunter2-correct')).rejects.toThrow(EmailNotConfirmedError);
299
+ await expect(Auth.login('bob', 'hunter2-correctA1')).rejects.toThrow(EmailNotConfirmedError);
284
300
 
285
301
  // Read the confirm link out of the captured email and confirm.
286
302
  const token = tokenFromEmail('confirm', 'bob@example.com');
287
303
  await Auth.confirmEmail(token);
288
304
 
289
305
  // Now login succeeds.
290
- const session = await Auth.login('bob', 'hunter2-correct');
306
+ const session = await Auth.login('bob', 'hunter2-correctA1');
291
307
  expect(session.length).toBeGreaterThan(0);
292
308
  },
293
309
  60_000,
@@ -296,10 +312,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
296
312
  it(
297
313
  'duplicate email is rejected at registration (distinct "email already in use")',
298
314
  async () => {
299
- await Auth.register('carol', 'carol-pw-strong', 'dupe@x.com');
315
+ await Auth.register('carol', 'carol-pw-strongA1', 'dupe@x.com');
300
316
  // Typed error: a distinct EmailInUseError carrying the stable code, so a UI can
301
317
  // branch reliably (never on the message/name string).
302
- const err = await Auth.register('dave', 'dave-pw-strong', 'dupe@x.com').catch(
318
+ const err = await Auth.register('dave', 'dave-pw-strongA1', 'dupe@x.com').catch(
303
319
  (e: unknown) => e,
304
320
  );
305
321
  expect(err).toBeInstanceOf(EmailInUseError);
@@ -311,18 +327,18 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
311
327
  it(
312
328
  'password reset round trip: old password stops working, the new one logs in',
313
329
  async () => {
314
- await Auth.register('erin', 'oldpw-erin-strong', 'erin@x.com');
330
+ await Auth.register('erin', 'oldpw-erin-strongA1', 'erin@x.com');
315
331
  // Sanity: confirmation is off, so she can log in before the reset.
316
- expect((await Auth.login('erin', 'oldpw-erin-strong')).length).toBeGreaterThan(0);
332
+ expect((await Auth.login('erin', 'oldpw-erin-strongA1')).length).toBeGreaterThan(0);
317
333
 
318
334
  await Auth.requestPasswordReset('erin@x.com');
319
335
  const token = tokenFromEmail('reset', 'erin@x.com');
320
- await Auth.resetPassword(token, 'newpw-erin-strong');
336
+ await Auth.resetPassword(token, 'newpw-erin-strongA1');
321
337
 
322
- await expect(Auth.login('erin', 'oldpw-erin-strong')).rejects.toThrow(
338
+ await expect(Auth.login('erin', 'oldpw-erin-strongA1')).rejects.toThrow(
323
339
  /login failed|request failed/,
324
340
  );
325
- expect((await Auth.login('erin', 'newpw-erin-strong')).length).toBeGreaterThan(0);
341
+ expect((await Auth.login('erin', 'newpw-erin-strongA1')).length).toBeGreaterThan(0);
326
342
  },
327
343
  60_000,
328
344
  );
@@ -341,10 +357,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
341
357
  it(
342
358
  'a reset token is one-time: replaying /auth/reset/finish with the consumed token fails',
343
359
  async () => {
344
- await Auth.register('frank', 'oldpw-frank-strong', 'frank@x.com');
360
+ await Auth.register('frank', 'oldpw-frank-strongA1', 'frank@x.com');
345
361
  await Auth.requestPasswordReset('frank@x.com');
346
362
  const tokenHex = tokenFromEmail('reset', 'frank@x.com');
347
- await Auth.resetPassword(tokenHex, 'newpw-frank-strong'); // consumes the token
363
+ await Auth.resetPassword(tokenHex, 'newpw-frank-strongA1'); // consumes the token
348
364
 
349
365
  // Replay reset/finish with a valid-length pk/proof so the controller reaches the
350
366
  // token-consume step, which now finds the token already deleted -> generic fail.
@@ -372,8 +388,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
372
388
  it(
373
389
  '(a) full email-2FA login round-trip: enable, login requires a code, verify mints the session',
374
390
  async () => {
375
- await Auth.register('dave', 'dave-pw-strong', 'dave@x.com');
376
- await Auth.login('dave', 'dave-pw-strong'); // 2FA off -> session
391
+ await Auth.register('dave', 'dave-pw-strongA1', 'dave@x.com');
392
+ await Auth.login('dave', 'dave-pw-strongA1'); // 2FA off -> session
377
393
 
378
394
  // Enable email 2FA: setup delivers a code, confirm switches the method on.
379
395
  await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
@@ -387,7 +403,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
387
403
  // verifies serverConfirm before throwing), but NO session is minted.
388
404
  let err: unknown;
389
405
  try {
390
- await Auth.login('dave', 'dave-pw-strong');
406
+ await Auth.login('dave', 'dave-pw-strongA1');
391
407
  } catch (e) {
392
408
  err = e;
393
409
  }
@@ -413,8 +429,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
413
429
  it(
414
430
  '(b) 2FA code security: wrong code rejected + attempt-limited to death, correct code single-use',
415
431
  async () => {
416
- await Auth.register('eve', 'eve-pw-strong', 'eve@x.com');
417
- await Auth.login('eve', 'eve-pw-strong');
432
+ await Auth.register('eve', 'eve-pw-strongA1', 'eve@x.com');
433
+ await Auth.login('eve', 'eve-pw-strongA1');
418
434
  await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
419
435
  await Auth.confirmTwoFactorSetup(codeFromEmail('eve@x.com'));
420
436
 
@@ -423,7 +439,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
423
439
  jar.clear();
424
440
  let err: unknown;
425
441
  try {
426
- await Auth.login('eve', 'eve-pw-strong');
442
+ await Auth.login('eve', 'eve-pw-strongA1');
427
443
  } catch (e) {
428
444
  err = e;
429
445
  }
@@ -447,7 +463,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
447
463
  __resetRatelimitForTests();
448
464
  let err2: unknown;
449
465
  try {
450
- await Auth.login('eve', 'eve-pw-strong');
466
+ await Auth.login('eve', 'eve-pw-strongA1');
451
467
  } catch (e) {
452
468
  err2 = e;
453
469
  }
@@ -464,7 +480,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
464
480
  it(
465
481
  '(c) cross-flow: reset tokens and 2FA codes are NOT interchangeable (namespace separation)',
466
482
  async () => {
467
- await Auth.register('frank', 'frank-pw-strong', 'frank@x.com');
483
+ await Auth.register('frank', 'frank-pw-strongA1', 'frank@x.com');
468
484
 
469
485
  // ---- direction 1: a LIVE reset token is useless at /auth/2fa/verify ----
470
486
  await Auth.requestPasswordReset('frank@x.com');
@@ -475,7 +491,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
475
491
  await expect(Auth.verifyTwoFactor(resetToken, resetToken)).rejects.toThrow();
476
492
 
477
493
  // ---- direction 2: a LIVE 2FA login challenge id is useless at /auth/reset/finish ----
478
- await Auth.login('frank', 'frank-pw-strong'); // session (2FA still off here)
494
+ await Auth.login('frank', 'frank-pw-strongA1'); // session (2FA still off here)
479
495
  __clearSentEmails();
480
496
  await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
481
497
  await Auth.confirmTwoFactorSetup(codeFromEmail('frank@x.com'));
@@ -484,7 +500,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
484
500
  __resetRatelimitForTests();
485
501
  let err: unknown;
486
502
  try {
487
- await Auth.login('frank', 'frank-pw-strong');
503
+ await Auth.login('frank', 'frank-pw-strongA1');
488
504
  } catch (e) {
489
505
  err = e;
490
506
  }
@@ -527,8 +543,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
527
543
  it(
528
544
  '(d) NO session cookie is set on the ST_TWOFA_REQUIRED login response',
529
545
  async () => {
530
- await Auth.register('gwen', 'gwen-pw-strong', 'gwen@x.com');
531
- await Auth.login('gwen', 'gwen-pw-strong');
546
+ await Auth.register('gwen', 'gwen-pw-strongA1', 'gwen@x.com');
547
+ await Auth.login('gwen', 'gwen-pw-strongA1');
532
548
  await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
533
549
  await Auth.confirmTwoFactorSetup(codeFromEmail('gwen@x.com'));
534
550
 
@@ -538,7 +554,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
538
554
 
539
555
  let err: unknown;
540
556
  try {
541
- await Auth.login('gwen', 'gwen-pw-strong');
557
+ await Auth.login('gwen', 'gwen-pw-strongA1');
542
558
  } catch (e) {
543
559
  err = e;
544
560
  }
@@ -556,8 +572,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
556
572
  it(
557
573
  '(e) login<->setup 2FA challenges are separate: a setup code cannot mint a login session, and setup/verify never silently disables 2FA',
558
574
  async () => {
559
- await Auth.register('ivy', 'ivy-pw-strong', 'ivy@x.com');
560
- await Auth.login('ivy', 'ivy-pw-strong'); // session; 2FA still off
575
+ await Auth.register('ivy', 'ivy-pw-strongA1', 'ivy@x.com');
576
+ await Auth.login('ivy', 'ivy-pw-strongA1'); // session; 2FA still off
561
577
 
562
578
  // Begin a 2FA SETUP: writes a challenge into `twoFaSetup` (keyed by
563
579
  // username), NOT a login challenge into `twoFaLogins`.
@@ -593,7 +609,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
593
609
  '(f) cross-flow: confirm tokens and 2FA codes are NOT interchangeable (namespace separation)',
594
610
  async () => {
595
611
  setConfirmation(true);
596
- await Auth.register('jade', 'jade-pw-strong', 'jade@x.com'); // unconfirmed -> confirm token emailed
612
+ await Auth.register('jade', 'jade-pw-strongA1', 'jade@x.com'); // unconfirmed -> confirm token emailed
597
613
  const confirmToken = tokenFromEmail('confirm', 'jade@x.com'); // live confirmTokens entry
598
614
 
599
615
  // ---- direction 1: a LIVE confirm token is useless at /auth/2fa/verify ----
@@ -607,7 +623,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
607
623
  __resetRatelimitForTests();
608
624
  await Auth.confirmEmail(confirmToken);
609
625
  __resetRatelimitForTests();
610
- expect((await Auth.login('jade', 'jade-pw-strong')).length).toBeGreaterThan(0);
626
+ expect((await Auth.login('jade', 'jade-pw-strongA1')).length).toBeGreaterThan(0);
611
627
 
612
628
  // ---- direction 2: a LIVE 2FA login id/code is useless at /auth/confirm ----
613
629
  // Enable 2FA (jade is confirmed + has a session from the login above).
@@ -622,7 +638,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
622
638
  __resetRatelimitForTests();
623
639
  let err: unknown;
624
640
  try {
625
- await Auth.login('jade', 'jade-pw-strong');
641
+ await Auth.login('jade', 'jade-pw-strongA1');
626
642
  } catch (e) {
627
643
  err = e;
628
644
  }
@@ -665,7 +681,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
665
681
  // ---- confirm token: minted on A, unredeemable on B ----
666
682
  setConfirmation(true);
667
683
  setHost(A);
668
- await Auth.register('mia', 'mia-pw-strong', 'mia@x.com'); // realm A + confirm token emailed
684
+ await Auth.register('mia', 'mia-pw-strongA1', 'mia@x.com'); // realm A + confirm token emailed
669
685
  const confirmTok = tokenFromEmail('confirm', 'mia@x.com');
670
686
 
671
687
  setHost(B);
@@ -687,16 +703,16 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
687
703
 
688
704
  setHost(B);
689
705
  __resetRatelimitForTests();
690
- await expect(Auth.resetPassword(resetTok, 'mia-pw-new')).rejects.toThrow(); // realm B miss
706
+ await expect(Auth.resetPassword(resetTok, 'mia-pw-newA1')).rejects.toThrow(); // realm B miss
691
707
 
692
708
  setHost(A);
693
709
  __resetRatelimitForTests();
694
- await Auth.resetPassword(resetTok, 'mia-pw-new'); // realm A -> resets
710
+ await Auth.resetPassword(resetTok, 'mia-pw-newA1'); // realm A -> resets
695
711
 
696
712
  // ---- 2FA login code: minted on A, unusable on B ----
697
713
  setHost(A);
698
714
  __resetRatelimitForTests();
699
- await Auth.login('mia', 'mia-pw-new'); // session on A (2FA still off)
715
+ await Auth.login('mia', 'mia-pw-newA1'); // session on A (2FA still off)
700
716
  __clearSentEmails();
701
717
  await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
702
718
  await Auth.confirmTwoFactorSetup(codeFromEmail('mia@x.com')); // 2FA enabled on A
@@ -706,7 +722,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
706
722
  __resetRatelimitForTests();
707
723
  let err: unknown;
708
724
  try {
709
- await Auth.login('mia', 'mia-pw-new'); // -> 2FA challenge (twoFaId + code, realm A)
725
+ await Auth.login('mia', 'mia-pw-newA1'); // -> 2FA challenge (twoFaId + code, realm A)
710
726
  } catch (e) {
711
727
  err = e;
712
728
  }
@@ -726,4 +742,60 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
726
742
  },
727
743
  180_000,
728
744
  );
745
+
746
+ // ---- input validation (client-side, before any crypto / network) ----
747
+
748
+ it('checkPassword + isValidEmail: the pure validators behave', () => {
749
+ expect(checkPassword('Str0ng-pw!')).toEqual([]); // meets every rule
750
+ expect(checkPassword('short1A').sort()).toEqual(['minLength', 'special'].sort()); // 7 chars, no special
751
+ expect(checkPassword('alllowercase1!')).toEqual(['uppercase']);
752
+ expect(checkPassword('NoDigits!!')).toEqual(['digit']);
753
+ expect(isValidEmail('bob@example.com')).toBe(true);
754
+ expect(isValidEmail('bobfwehfuewhfwf.com')).toBe(false); // no @
755
+ expect(isValidEmail('a@b')).toBe(false); // no dotted domain
756
+ expect(isValidEmail('a b@x.com')).toBe(false); // space
757
+ });
758
+
759
+ it('register rejects a weak password with WeakPasswordError (before any network)', async () => {
760
+ const err = await Auth.register('newbie', 'weak', 'newbie@x.com').catch((e: unknown) => e);
761
+ expect(err).toBeInstanceOf(WeakPasswordError);
762
+ expect((err as WeakPasswordError).code).toBe(AuthErrorCode.WeakPassword);
763
+ // the unmet rules are exposed so a UI can show a checklist
764
+ expect((err as WeakPasswordError).unmet).toContain('minLength');
765
+ expect((err as WeakPasswordError).unmet).toContain('uppercase');
766
+ // no confirm email was sent (it threw before the request)
767
+ __clearSentEmails();
768
+ expect(__sentEmails.length).toBe(0);
769
+ });
770
+
771
+ it('register rejects an invalid email + a bad username with typed errors', async () => {
772
+ await expect(Auth.register('newbie', 'Str0ng-pw!', 'not-an-email')).rejects.toBeInstanceOf(
773
+ InvalidEmailError,
774
+ );
775
+ await expect(Auth.register('', 'Str0ng-pw!', 'ok@x.com')).rejects.toBeInstanceOf(
776
+ InvalidUsernameError,
777
+ );
778
+ });
779
+
780
+ it(
781
+ 'AUTH_EMAIL_CONFIRMATION sends the verify email at register but does NOT block login',
782
+ async () => {
783
+ // The decoupled flag: verify-at-register, login NOT gated (unlike the
784
+ // stricter AUTH_REQUIRE_EMAIL_CONFIRMATION, which also blocks login).
785
+ setEmailConfirmation(true);
786
+ __clearSentEmails();
787
+ await Auth.register('kev', 'Kev-pw-strong1!', 'kev@x.com');
788
+ // a confirm link was emailed (the account is stored unconfirmed)
789
+ const tok = tokenFromEmail('confirm', 'kev@x.com');
790
+ expect(tok.length).toBeGreaterThan(0);
791
+ // login SUCCEEDS despite the unconfirmed email (no login gate)
792
+ __resetRatelimitForTests();
793
+ const session = await Auth.login('kev', 'Kev-pw-strong1!');
794
+ expect(session.length).toBeGreaterThan(0);
795
+ // and the emailed confirm link still works
796
+ __resetRatelimitForTests();
797
+ await Auth.confirmEmail(tok);
798
+ },
799
+ 90_000,
800
+ );
729
801
  });