Skip to content

Commit a7293f7

Browse files
fix(plugin-auth): a sign-up answers without waiting for any mail send, whether or not the address already has an account (#29975)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:注册接口 `POST /api/v1/auth/sign-up/email` 不再等任何邮件发送完成就作答——新地址的验证邮件、以及发给已有账号地址的提醒邮件,都改在响应之后发出,沿用找回密码邮件已在用的同一个助手。 - 为什么改:注册无论地址是否已有账号,都应一样作答、不等发信;这与找回密码请求已落地的规则一致。 - 风险与回滚:响应后才发信,进程若在响应与发信之间退出会丢这封信(与原先请求中途退出等价);重发验证邮件接口仍同步等待并如实报错。回滚即 revert 本 PR。 - 先看:`auth-manager-auth-instance.ts` 里的 `sendAfterResponseOnSignUp` 与两个回调的包装;`signup-existing-address-success-shape.test.ts` 的 ⑦ 组;`run-after-response.ts` 的日志一行。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/signup-existing-address-success-shape.test.ts`,预期 18 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **The rule.** `POST /api/v1/auth/sign-up/email` answers without waiting for any mail send, whether or not the address already has an account. On the public door (a call that carries its request) the verification mail and the notice to an address that already has an account are built and sent after the response, through `sendAfterResponseOnPublicDoor`, the helper the password-reset mail already uses. A send that fails is logged on the server at error and never reaches the caller, whose answer is the one a sent mail gets. - **Only the sign-up's verification send moves.** The verification-mail callback serves more doors than sign-up, so `sendAfterResponseOnSignUp` hands the helper only the call the sign-up endpoint makes, read by the endpoint's declared path from better-auth's endpoint context (as the hooks read `ctx.path`), never from the request URL. Every other call is awaited exactly as before, above all `/send-verification-email`, which still reports a failed send to its caller. Outside any endpoint context the call is awaited. - **A server-side call is unchanged.** `auth.api.signUpEmail` carries no request and still awaits both sends. - **The failure log names its medium in `what`.** `logAfterResponseSendFailure` now prints `what` as given (`password-reset email`, `verification email`, `sign-up notice email`). This hunk, and the reset call site's `what`, are byte-identical to the sibling draft that moves the phone reset text, so the two merge cleanly in either order (`git merge-tree` against its head: no conflict). The reset's own log line reads exactly as before. The notice keeps its own error line for a failed send and never throws; `onFailure` is its backstop. - **Unchanged.** The answer's status and body, the sign-up rate limits, the mails themselves. ## Tests - `signup-existing-address-success-shape.test.ts` gains ⑦, one top-level `describe` in the existing harness file: - with `sendOnSignUp` off and on, both kinds of address get the same answer while every send is held open by a deferred, and the mail still goes out once it is released; - a verification send that fails is logged on the server at error, and the sign-up answers as one whose mail is sent; - the server-side sign-up call (no `Request`) still awaits its sends; - the `/send-verification-email` resend still awaits its send and answers 500 for one that failed. - The existing pins ①, ②, ⑤ and ⑥ wait for the mail to arrive (`vi.waitFor`) before reading it; what each asserts is unchanged. The negative mail assertions in ②, ③ and ④ follow an explicit refusal, where no send is ever scheduled. ## Acceptance notes - A consumer that reads the verification mail the instant the sign-up answers now sees it a moment later; the plugin-auth suite has no such reader left, and a `git grep` over test files outside this package finds none. - Ablation (one-shot, each leg restored and proven by blob hash): removing the notice wrapper reds both ⑦ answer cases; removing the verification wrapper reds the `sendOnSignUp on` answer case and the failure-log case; wrapping every verification call reds the resend case. Removing a wrapper fails by the case timeout, because the answer then waits on the held send. ## Verification <details><summary>plugin-auth suite at 7a467a8</summary> ``` pnpm --filter @objectstack/plugin-auth test (under scripts/pm/os-verify-lock.sh) Test Files 219 passed (219) Tests 3771 passed | 10 skipped (3781) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>plugin-auth typecheck at 7a467a8</summary> ``` pnpm --filter @objectstack/plugin-auth typecheck (under scripts/pm/os-verify-lock.sh) check:test-typecheck: OK — 10 file(s) / 88 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablation at 6a399c2 (source identical at 7a467a8)</summary> ``` leg notice (onExistingUserSignUp wrapper removed) exit 1 · 2 failed | 3 passed (⑦ only) · restored: blob == HEAD leg verify (sendVerificationEmail wrapper removed) exit 1 · 2 failed | 3 passed (⑦ only) · restored: blob == HEAD leg wrap-all (every verification call after the response) exit 1 · 1 failed | 4 passed (⑦ only) · restored: blob == HEAD ``` </details> <details><summary>Derived gates at 7a467a8</summary> ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack # 59 commands, tree 7a467a8 # every command run, exit code captured before any pipe; reconciled with --ran: # 59 derived famil(ies) accounted for — 58 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3) node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 # PREREQUISITE NOT MET: reads every package dist/, this tree built plugin-auth and its closure only # NOT MEASURED locally; Lint & Repo Gates runs it. Targeted substitute: require() of plugin-auth dist/index.js loads (160 exports). # also run, rosters under the changed paths (outside the derivation): pnpm check:auth-mount-ledger :: exit 0 pnpm check:authz-resolver :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:filter-alias-parity :: exit 0 ``` </details> ## CI note (seat) - `Type Check · workspace` on `7a467a8031` failed because `Checkout repository` timed out after 20 minutes, before any step ran on the code (the same runner-side timeout hit merge-group run 41719580038 the same hour). Treated as a flake and re-run once through the relay. - `Check Changeset` on this head was cancelled with no superseding run; this body edit re-triggers it. <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 5cdaddb commit a7293f7

3 files changed

Lines changed: 226 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a sign-up answers without waiting for any mail send, whether or not the address already has an account
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **The rule.** `POST /api/v1/auth/sign-up/email` answers without waiting for any mail send, whether or not the address already has an account: the verification mail, and the notice to an address that already has an account, are built and sent after the response, as the password-reset mail already is.
11+
- **A send that fails is logged on the server**, at error (`verification email was not delivered`; the notice keeps its own error line), and never reaches the caller, whose answer is the one a sent mail gets, as before.
12+
- **Unchanged.** The answer's status and body; the sign-up rate limits; the mails themselves; the `/send-verification-email` resend, which still awaits its send and still reports one that failed; and a server-side `auth.api.signUpEmail` call, which carries no request and still awaits its sends.

‎packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
*/
5353

5454
import type { Auth, BetterAuthOptions } from 'better-auth';
55+
import { tryGetCurrentAuthEndpointContext } from '@better-auth/core/context';
5556
import { audiencePermitsSelfRegistration } from '@objectstack/spec/system';
5657
import { readEnvWithDeprecation } from '@objectstack/types';
5758
import { readAccountInvitation, settleImportResetDelivery } from './account-invitation-marker.js';
@@ -70,6 +71,38 @@ import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-
7071
import { isPlaceholderEmail } from './placeholder-email.js';
7172
import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js';
7273

74+
/** The path better-auth declares for the email sign-up endpoint. */
75+
const SIGN_UP_EMAIL_PATH = '/sign-up/email';
76+
77+
/**
78+
* `send`, with the one call the sign-up endpoint makes handed to
79+
* {@link sendAfterResponseOnPublicDoor}: a sign-up answers without waiting for
80+
* any mail send, whether or not the address already has an account. Every
81+
* other call is `send` itself, awaited exactly as before — the
82+
* `/send-verification-email` resend among them, which reports a send that
83+
* failed to its caller.
84+
*
85+
* The endpoint is the one better-auth runs the call inside, read by its
86+
* declared path from the endpoint context (as the hooks read `ctx.path`),
87+
* never from the request's URL. Outside any endpoint context the call is
88+
* awaited.
89+
*/
90+
function sendAfterResponseOnSignUp<D>(
91+
send: (data: D, request?: unknown) => Promise<unknown>,
92+
onFailure: (error: unknown) => void,
93+
): (data: D, request?: unknown) => Promise<void> {
94+
const afterResponse = sendAfterResponseOnPublicDoor(send, onFailure);
95+
return (data, request) => {
96+
let path: unknown;
97+
try {
98+
path = tryGetCurrentAuthEndpointContext()?.path;
99+
} catch {
100+
path = undefined;
101+
}
102+
return path === SIGN_UP_EMAIL_PATH ? afterResponse(data, request) : (send(data, request) as Promise<void>);
103+
};
104+
}
105+
73106
/**
74107
* The `AuthManager` members the function below reads — every one of them, and
75108
* no other. Each is typed AS the class member (an indexed access on the
@@ -505,16 +538,28 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
505538
// address that already has an account answers like a success". The
506539
// vendor calls this only when the address already has a row AND its
507540
// anti-enumeration shield is on, after its timing-equalizing password
508-
// hash, and awaits it inline (no background-task handler is set);
509-
// what it throws is caught and logged by the vendor, and the answer is
510-
// its generic success either way (better-auth 1.7.12
541+
// hash; what it throws is caught and logged by the vendor, and the
542+
// answer is its generic success either way (better-auth 1.7.12
511543
// `routes/sign-up.mjs`, re-read for #25060). So the DECISION cannot
512544
// live here: it is made first, in the `/sign-up/email` before-hook,
513545
// which keeps the explicit refusal wherever this mail could not be
514546
// promised. This hands over the existing row, which is what the
515547
// recipient's stored-locale rung needs.
516-
onExistingUserSignUp: async ({ user }: { user: { id: string; email: string; name?: string | null } }) =>
517-
this.sendExistingAccountSignUpNotice(user),
548+
//
549+
// A sign-up answers without waiting for any mail send, whether or not
550+
// the address already has an account: the vendor hands this callback
551+
// the request (`onExistingUserSignUp(data, request)`, the same
552+
// routes/sign-up.mjs), so a call that carries one — the public door —
553+
// resolves at once and the notice is built and sent after the response
554+
// (`sendAfterResponseOnPublicDoor`), as the verification mail of a new
555+
// address's sign-up is (`sendVerificationEmail` below). A server-side
556+
// call (no request) still awaits it. The notice reports its own failed
557+
// send at error level and never throws; `onFailure` logs anything else.
558+
onExistingUserSignUp: sendAfterResponseOnPublicDoor(
559+
async ({ user }: { user: { id: string; email: string; name?: string | null } }) =>
560+
this.sendExistingAccountSignUpNotice(user),
561+
(error) => logAfterResponseSendFailure(this.config.logger, 'sign-up notice email', error),
562+
),
518563
};
519564
})(),
520565

@@ -530,7 +575,15 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
530575
...(this.config.emailVerification?.expiresIn != null
531576
? { expiresIn: this.config.emailVerification.expiresIn } : {}),
532577
// #14319 — better-auth calls this as `sendVerificationEmail(data, ctx.request)`.
533-
sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
578+
//
579+
// A sign-up answers without waiting for any mail send, whether or not
580+
// the address already has an account: the call the sign-up endpoint
581+
// makes, carrying its request, resolves at once and the mail is built
582+
// and sent after the response, as the notice to an address that
583+
// already has an account is (`onExistingUserSignUp` above); its
584+
// failure is logged on the server. Every other call is awaited as
585+
// before (`sendAfterResponseOnSignUp`) — the resend below among them.
586+
sendVerificationEmail: sendAfterResponseOnSignUp(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
534587
const email = this.getEmailService();
535588
if (!email) {
536589
// Verification is enabled (this callback only exists when it is)
@@ -579,7 +632,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
579632
`Verification email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
580633
);
581634
}
582-
},
635+
}, (error) => logAfterResponseSendFailure(this.config.logger, 'verification email', error)),
583636
},
584637
} : {}),
585638

‎packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts‎

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,23 @@
2727
* - ⑤ the mail's language: the recipient's stored locale, then the
2828
* deployment default — never the requester's browser language;
2929
* - ⑥ a send that fails after the decision still answers the success (the
30-
* accepted residual) and says so at error level.
30+
* accepted residual) and says so at error level;
31+
* - ⑦ a sign-up answers without waiting for any mail send, whether or not
32+
* the address already has an account: with `sendOnSignUp` off and on, both
33+
* kinds of address get the same answer while every send is still open, and
34+
* the mails still go out after the response; a verification send that fails
35+
* is logged on the server and answered like one that is sent; the
36+
* server-side sign-up call (no `Request`) still awaits its sends, and the
37+
* `/send-verification-email` resend still reports a send that failed.
38+
*
39+
* The mail of ①, ⑤ and ⑥ is sent after the response, so those cases wait for
40+
* it to arrive before reading it.
3141
*
3242
* Real engine for the same reason the sibling file gives: the uniqueness probe
3343
* and the row the vendor finds both live below any double.
3444
*/
3545

36-
import { describe, it, expect, afterEach } from 'vitest';
46+
import { describe, it, expect, afterEach, vi } from 'vitest';
3747
import { ObjectQL } from '@objectstack/objectql';
3848
import { SqlDriver } from '@objectstack/driver-sql';
3949
import { AuthManager } from './auth-manager.js';
@@ -246,7 +256,7 @@ describe('[#25060] ① an existing address gets the success shape plus the mail'
246256
const fresh = await signUp(manager, 'dave@corp.example');
247257
expect(fresh.status, `a new registrant was refused: ${await fresh.clone().text()}`).toBeLessThan(300);
248258
const freshShape = await shapeOf(fresh);
249-
expect(email.sent.map((s) => s.template)).toEqual(['auth.verify_email']);
259+
await vi.waitFor(() => expect(email.sent.map((s) => s.template)).toEqual(['auth.verify_email']));
250260
email.sent.length = 0;
251261
errors.length = 0;
252262
warnings.length = 0;
@@ -260,6 +270,10 @@ describe('[#25060] ① an existing address gets the success shape plus the mail'
260270
expect(existingShape.token).toBeNull();
261271
expect(existingShape.headerNames).not.toContain('set-cookie');
262272

273+
// The mail goes out after the response; everything below reads the
274+
// state once it has.
275+
await vi.waitFor(() => expect(email.sent).toHaveLength(1));
276+
263277
// Nothing was written for the existing address: no row, no credential, no session.
264278
expect(inserts).toEqual([]);
265279
expect((await readAll(engine, 'sys_user')).filter((u) => u.email === EXISTING).map((u) => u.id)).toEqual(['usr_alice']);
@@ -301,7 +315,8 @@ describe('[#25060] ① an existing address gets the success shape plus the mail'
301315
const existing = await signUp(manager, EXISTING);
302316

303317
expect(await shapeOf(existing)).toEqual(await shapeOf(fresh));
304-
expect(email.sent.filter((s) => s.template === NOTICE).map((s) => s.to.address)).toEqual([EXISTING]);
318+
await vi.waitFor(() =>
319+
expect(email.sent.filter((s) => s.template === NOTICE).map((s) => s.to.address)).toEqual([EXISTING]));
305320
});
306321
});
307322

@@ -330,7 +345,7 @@ describe('[#25060] ② no live transport keeps the refusal', () => {
330345
email.state.live = true;
331346
const live = await signUp(manager, EXISTING);
332347
expect(live.status, await live.clone().text()).toBeLessThan(300);
333-
expect(email.sent.map((s) => s.template)).toEqual([NOTICE]);
348+
await vi.waitFor(() => expect(email.sent.map((s) => s.template)).toEqual([NOTICE]));
334349
email.state.live = false;
335350
await expectRefused(await signUp(manager, EXISTING));
336351
expect(email.sent).toHaveLength(1);
@@ -391,7 +406,7 @@ describe('[#25060] ⑤ the mail is in the recipient\'s language', () => {
391406

392407
await signUp(manager, EXISTING, { 'accept-language': 'es-ES,es;q=0.9' });
393408

394-
expect(email.sent.map((s) => [s.template, s.locale])).toEqual([[NOTICE, 'ja-JP']]);
409+
await vi.waitFor(() => expect(email.sent.map((s) => [s.template, s.locale])).toEqual([[NOTICE, 'ja-JP']]));
395410
});
396411

397412
it('with no stored locale it is the deployment default — never the requester\'s Accept-Language', async () => {
@@ -402,7 +417,8 @@ describe('[#25060] ⑤ the mail is in the recipient\'s language', () => {
402417

403418
await signUp(manager, 'bob@corp.example', { 'accept-language': 'es-ES,es;q=0.9' });
404419

405-
expect(email.sent.map((s) => [s.template, s.to.address, s.locale])).toEqual([[NOTICE, 'bob@corp.example', 'zh-CN']]);
420+
await vi.waitFor(() =>
421+
expect(email.sent.map((s) => [s.template, s.to.address, s.locale])).toEqual([[NOTICE, 'bob@corp.example', 'zh-CN']]));
406422
});
407423
});
408424

@@ -416,8 +432,139 @@ describe('[#25060] ⑥ a send that fails after the decision', () => {
416432
const res = await signUp(manager, EXISTING);
417433

418434
expect(res.status, await res.clone().text()).toBeLessThan(300);
435+
await vi.waitFor(() => expect(errors.filter((l) => l.includes('could NOT be sent'))).toHaveLength(1));
419436
expect(email.sent.map((s) => s.template)).toEqual([NOTICE]);
420437
const named = errors.filter((l) => l.includes('could NOT be sent'));
421438
expect(named, `logged: ${JSON.stringify(errors)}`).toHaveLength(1);
422439
});
423440
});
441+
442+
describe('⑦ a sign-up answers without waiting for any mail send, whether or not the address already has an account', () => {
443+
const NEW = 'dave@corp.example';
444+
const VERIFY = 'auth.verify_email';
445+
446+
/** A promise the case settles by hand. */
447+
function deferred(): { promise: Promise<void>; resolve: () => void } {
448+
let resolve!: () => void;
449+
const promise = new Promise<void>((r) => {
450+
resolve = r;
451+
});
452+
return { promise, resolve };
453+
}
454+
455+
/**
456+
* A live email service whose every send waits on `gate` and then answers
457+
* `status()`; it records each send it settled, as `[template, address]`.
458+
*/
459+
function gatedEmail(gate: () => Promise<void>, status: () => 'sent' | 'failed' = () => 'sent') {
460+
const settled: Array<[string, string]> = [];
461+
const service = {
462+
async send() { return { id: 'e', status: 'sent' }; },
463+
async sendTemplate(input: any) {
464+
try {
465+
await gate();
466+
return status() === 'failed'
467+
? { id: 'e', status: 'failed', error: 'smtp refused (simulated)' }
468+
: { id: 'e', status: 'sent' };
469+
} finally {
470+
settled.push([input.template, input.to.address]);
471+
}
472+
},
473+
isConfigured: () => true,
474+
};
475+
return { settled, service };
476+
}
477+
478+
const sorted = (pairs: ReadonlyArray<readonly [string, string]>) => pairs.map((p) => p.join(' ')).sort();
479+
480+
for (const [label, sendOnSignUp, mails] of [
481+
['sendOnSignUp off', false, [[NOTICE, EXISTING]]],
482+
['sendOnSignUp on', true, [[VERIFY, NEW], [NOTICE, EXISTING]]],
483+
] as const) {
484+
it(`${label}: both kinds of address get the same answer while every send is still open, and the mail still goes out`, async () => {
485+
const engine = await bootEngine();
486+
const open = deferred();
487+
const email = gatedEmail(() => open.promise);
488+
const { errors, logger } = recordingLogger();
489+
const manager = makeManager(engine, email, { ...OPEN, emailVerification: { sendOnSignUp }, logger });
490+
491+
const forNew = await signUp(manager, NEW);
492+
const forExisting = await signUp(manager, EXISTING);
493+
494+
expect(forNew.status, await forNew.clone().text()).toBe(200);
495+
expect(await shapeOf(forExisting)).toEqual(await shapeOf(forNew));
496+
// Both answers arrived while every send was still open.
497+
expect(email.settled).toEqual([]);
498+
499+
// The control: the mail still goes out, after the response.
500+
open.resolve();
501+
await vi.waitFor(() => expect(sorted(email.settled)).toEqual(sorted(mails)));
502+
expect(errors, `logged: ${JSON.stringify(errors)}`).toEqual([]);
503+
});
504+
}
505+
506+
it('a verification send that fails is logged on the server at error, and the sign-up answers as one whose mail is sent', async () => {
507+
const failingLog = recordingLogger();
508+
const failing = makeManager(await bootEngine(), gatedEmail(async () => {}, () => 'failed'), {
509+
...OPEN,
510+
emailVerification: { sendOnSignUp: true },
511+
logger: failingLog.logger,
512+
});
513+
const sending = makeManager(await bootEngine(), gatedEmail(async () => {}), {
514+
...OPEN,
515+
emailVerification: { sendOnSignUp: true },
516+
});
517+
518+
const throughFailing = await signUp(failing, NEW);
519+
const throughSending = await signUp(sending, NEW);
520+
521+
expect(await shapeOf(throughFailing)).toEqual(await shapeOf(throughSending));
522+
await vi.waitFor(() => expect(failingLog.errors).toHaveLength(1));
523+
expect(failingLog.errors[0]).toContain('verification email was not delivered');
524+
expect(failingLog.errors[0]).toContain('smtp refused (simulated)');
525+
});
526+
527+
it('the server-side sign-up call (no Request) still awaits its sends', async () => {
528+
const engine = await bootEngine();
529+
const email = gatedEmail(() => new Promise<void>((resolve) => setImmediate(resolve)));
530+
const manager = makeManager(engine, email, { ...OPEN, emailVerification: { sendOnSignUp: true } });
531+
const api = (await manager.getApi()) as unknown as {
532+
signUpEmail(input: { body: { email: string; password: string; name: string } }): Promise<unknown>;
533+
};
534+
535+
await api.signUpEmail({ body: { email: NEW, password: PASSWORD, name: 'Someone' } });
536+
await api.signUpEmail({ body: { email: EXISTING, password: PASSWORD, name: 'Someone' } });
537+
538+
expect(sorted(email.settled)).toEqual(sorted([[VERIFY, NEW], [NOTICE, EXISTING]]));
539+
});
540+
541+
it('the /send-verification-email resend still awaits its send and reports one that failed', async () => {
542+
// A deployment where a new sign-up is signed in at once, so the resend
543+
// runs for the caller's own session.
544+
const engine = await bootEngine();
545+
let status: 'sent' | 'failed' = 'sent';
546+
const email = gatedEmail(async () => {}, () => status);
547+
const manager = makeManager(engine, email, { ...OPEN, emailAndPassword: { requireEmailVerification: false } });
548+
const signedUp = await signUp(manager, NEW);
549+
expect(signedUp.status, await signedUp.clone().text()).toBe(200);
550+
const cookie = signedUp.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
551+
const resend = () =>
552+
manager.handleRequest(
553+
new Request(`${AUTH}/send-verification-email`, {
554+
method: 'POST',
555+
headers: { 'content-type': 'application/json', origin: BASE, cookie },
556+
body: JSON.stringify({ email: NEW }),
557+
}),
558+
);
559+
560+
const sent = await resend();
561+
expect(sent.status, await sent.clone().text()).toBe(200);
562+
expect(await sent.json()).toEqual({ status: true });
563+
expect(email.settled).toEqual([[VERIFY, NEW]]);
564+
565+
status = 'failed';
566+
const failed = await resend();
567+
expect(failed.status, await failed.clone().text()).toBe(500);
568+
expect(email.settled).toEqual([[VERIFY, NEW], [VERIFY, NEW]]);
569+
});
570+
});

0 commit comments

Comments
 (0)