Repository navigation
Expand file tree
/
Copy pathadmin-import-users-invite-delivery-failure.test.ts
More file actions
315 lines (278 loc) · 13.1 KB
/
Copy pathadmin-import-users-invite-delivery-failure.test.ts
File metadata and controls
315 lines (278 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#23598] A user import reports an invitation it could not deliver.
*
* The import invites an email row by asking better-auth for a password reset,
* and better-auth runs `sendResetPassword` through `runInBackgroundOrAwait`,
* which catches and logs whatever the send throws and still answers
* `{ status: true }` (better-auth 1.7.12, `dist/context/create-context.mjs`,
* re-measured 2026-10-21 for #25096). So a
* throw inside the send can never reach the import. `sendResetPassword` now
* settles a delivery receipt the import's own request carries
* (`account-invitation-marker.ts`), and the import reads it after the request
* returns.
*
* Real stack: `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the
* objects from this plugin's manifest, a real `AuthManager` with the origin
* check ON, and an email service whose answer is chosen per recipient. Only the
* platform-admin gate's session read is a stand-in on the mounted route.
*
* Pinned:
* ① Through the plugin's REAL `POST /admin/import-users` mount, a transport
* that ANSWERS `failed`: the row is not ok, carries no `delivery`, names
* `INVITE_EMAIL_FAILED` and the transport's reason, and `summary.errors`
* counts it. The account itself was created.
* ② The same through a transport that THROWS.
* ③ NEGATIVE CONTROL: a transport that answers `sent` (and one that answers
* `queued`) is reported as today: `delivery: 'email'`, no code, no error.
* ④ One batch, a failing and a succeeding recipient: each row gets its own
* verdict, so the receipt is per row.
* ⑤ The row whose inviter cannot be named (no invitation marker, the reset
* copy) is reported failed the same way: the receipt does not ride the
* invitation marker.
* ⑥ A public `POST /request-password-reset` through a failing transport
* answers exactly what it answers through a succeeding one, and what it
* answers for an address with no account.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { Hono } from 'hono';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import type { PluginContext } from '@objectstack/core';
import { AuthManager } from './auth-manager.js';
import { AuthPlugin } from './auth-plugin.js';
import { authIdentityObjects } from './manifest.js';
import { runAdminImportUsers } from './admin-import-users.js';
const ORIGIN = 'https://acme.example.com';
const BASE = '/api/v1/auth';
const SECRET = 'test-secret-at-least-32-chars-long-23598';
const ADMIN_EMAIL = 'ada.admin@acme.example.com';
const REFUSAL = '550 5.1.1 mailbox unavailable';
const OUTAGE = 'connect ECONNREFUSED 10.0.0.25:587';
type Answer = 'sent' | 'queued' | 'failed' | 'throws';
type SentMail = { template: string; to: { address: string } };
type Row = Record<string, unknown>;
type ImportAnswer = { rows: Row[]; summary: Record<string, any> };
/** How the email service answers each recipient; unlisted addresses are `sent`. */
const answers = new Map<string, Answer>();
const attempts: SentMail[] = [];
let engine: ObjectQL;
let manager: AuthManager;
let app: Hono;
const mockCtx = (): PluginContext =>
({
registerService: vi.fn(),
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger: vi.fn(),
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
getKernel: vi.fn(),
}) as unknown as PluginContext;
beforeAll(async () => {
// better-auth logs the swallowed send failure; the assertions are on the answer.
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
engine = new ObjectQL();
engine.registerDriver(
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
true,
);
await engine.init();
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
await engine.syncSchemas();
manager = new AuthManager({
secret: SECRET,
baseUrl: ORIGIN,
dataEngine: engine as never,
plugins: { admin: true },
} as never);
manager.setEmailService({
sendTemplate: async (input: SentMail) => {
attempts.push(input);
const answer = answers.get(input.to.address) ?? 'sent';
if (answer === 'throws') throw new Error(OUTAGE);
if (answer === 'failed') return { id: 'em_failed', status: 'failed', error: REFUSAL };
return { id: 'em_ok', status: answer };
},
} as never);
const authContext = (await manager.getAuthContext()) as { skipOriginCheck: unknown };
authContext.skipOriginCheck = false;
const realApi = (await manager.getApi()) as any;
const created = await realApi.createUser({
body: { email: ADMIN_EMAIL, name: 'Ada Admin', password: 'Adm1n!Passw0rd-23598' },
});
const admin = { id: String(created.user.id), email: ADMIN_EMAIL };
// The plugin's REAL route registration; only the gate's session read is a stand-in.
app = new Hono();
const ctx = mockCtx();
const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } });
await plugin.init(ctx);
const gated = new Proxy(manager, {
get(target, key) {
if (key === 'getApi') {
return async () => ({ ...realApi, getSession: async () => ({ user: { ...admin, isPlatformAdmin: true } }) });
}
const value = Reflect.get(target, key, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
(plugin as unknown as { authManager: unknown }).authManager = gated;
(plugin as unknown as {
registerAuthRoutes(server: unknown, ctx: PluginContext): void;
}).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
}, 120_000);
afterAll(async () => {
vi.restoreAllMocks();
try {
await (engine as unknown as { destroy?(): Promise<void> })?.destroy?.();
} catch {
/* noop */
}
});
const importBody = (emails: string[], passwordPolicy: 'invite' | 'auto' = 'invite') =>
JSON.stringify({
format: 'json',
mode: 'insert',
passwordPolicy,
rows: emails.map((email, i) => ({ email, name: `Invitee ${i + 1}` })),
});
async function importThroughMount(emails: string[], passwordPolicy: 'invite' | 'auto' = 'invite'): Promise<ImportAnswer> {
const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: ORIGIN },
body: importBody(emails, passwordPolicy),
});
const answer = (await res.json()) as { data?: ImportAnswer };
expect(res.status, JSON.stringify(answer)).toBe(200);
return answer.data!;
}
/** The account the import created, read back off `sys_user`. */
async function userRow(email: string): Promise<Row | undefined> {
const found = await engine.find('sys_user', { where: { email }, limit: 1, context: { isSystem: true } } as never);
return (found as Row[])[0];
}
function expectDeliveryFailed(row: Row, reason: string): void {
expect(row.delivery, 'nothing was delivered').toBeUndefined();
expect(row.code).toBe('INVITE_EMAIL_FAILED');
expect(String(row.error)).toContain(reason);
expect(row.ok, 'the invitation the administrator asked for did not happen').toBe(false);
expect(row.action, 'the account was created').toBe('created');
expect(row.id).toEqual(expect.any(String));
}
function expectDelivered(row: Row): void {
expect(row.action).toBe('created');
expect(row.ok).toBe(true);
expect(row.delivery).toBe('email');
expect(row.code).toBeUndefined();
expect(row.error).toBeUndefined();
}
describe('[#23598] the import reports an invitation the email transport did not deliver', () => {
it('① a transport that answers failed: the row is failed, not delivered, and summary.errors counts it', async () => {
const email = 'answers-failed@acme.example.com';
answers.set(email, 'failed');
const { rows, summary } = await importThroughMount([email]);
// The send really was attempted, with the invitation copy.
expect(attempts.filter((m) => m.to.address === email).map((m) => m.template)).toEqual(['auth.account_invitation']);
expectDeliveryFailed(rows[0]!, REFUSAL);
expect(summary.created).toBe(1);
expect(summary.errors).toBe(1);
expect(summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 0 });
expect(await userRow(email), 'not a rollback: the account exists').toBeTruthy();
}, 60_000);
it('② a transport that throws: the same verdict', async () => {
const email = 'throws@acme.example.com';
answers.set(email, 'throws');
const { rows, summary } = await importThroughMount([email], 'auto');
expect(attempts.filter((m) => m.to.address === email)).toHaveLength(1);
expectDeliveryFailed(rows[0]!, OUTAGE);
// `auto` does not quietly downgrade a failed invitation to a temporary password.
expect(rows[0]!.temporaryPassword).toBeUndefined();
expect(summary.errors).toBe(1);
expect(summary.delivery.emailInvite).toBe(0);
}, 60_000);
it('③ NEGATIVE CONTROL: a transport that answers sent or queued is reported as today', async () => {
const sent = 'answers-sent@acme.example.com';
const queued = 'answers-queued@acme.example.com';
answers.set(queued, 'queued');
const { rows, summary } = await importThroughMount([sent, queued]);
expectDelivered(rows[0]!);
expectDelivered(rows[1]!);
expect(summary.created).toBe(2);
expect(summary.errors).toBe(0);
expect(summary.delivery).toEqual({ emailInvite: 2, smsInvite: 0, temporary: 0 });
}, 60_000);
it('④ one batch, a failing and a succeeding recipient: each row carries its own verdict', async () => {
const failing = 'batch-failing@acme.example.com';
const fine = 'batch-fine@acme.example.com';
answers.set(failing, 'failed');
const { rows, summary } = await importThroughMount([fine, failing]);
expectDelivered(rows[0]!);
expectDeliveryFailed(rows[1]!, REFUSAL);
expect(summary.created).toBe(2);
expect(summary.errors).toBe(1);
expect(summary.delivery.emailInvite).toBe(1);
}, 60_000);
it('⑤ a row sent the reset copy (no nameable inviter) is reported failed the same way', async () => {
const email = 'no-inviter-failing@acme.example.com';
answers.set(email, 'failed');
const res = await runAdminImportUsers(
{
getAuthApi: () => manager.getApi(),
getDataEngine: () => manager.getDataEngine() as never,
phoneNumberEnabled: () => false,
emailServiceAvailable: () => manager.isEmailServiceAvailable(),
smsInviteAvailable: () => false,
sendInviteSms: async () => {},
noteMustChangePasswordIssued: () => {},
logger: { warn: () => {} },
},
new Request(`${ORIGIN}${BASE}/admin/import-users`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: importBody([email]),
}),
// An actor with no address anywhere: no invitation marker is set.
{ id: 'usr_nameless_23598' },
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
const data = res.body.data as ImportAnswer;
expect(attempts.filter((m) => m.to.address === email).map((m) => m.template)).toEqual(['auth.password_reset']);
expectDeliveryFailed(data.rows[0]!, REFUSAL);
expect(data.summary.errors).toBe(1);
}, 60_000);
it('⑥ a public reset through a failing transport answers exactly as through a succeeding one', async () => {
const failing = 'public-failing@acme.example.com';
const fine = 'public-fine@acme.example.com';
const api = (await manager.getApi()) as any;
for (const email of [failing, fine]) {
await api.createUser({ body: { email, name: 'Public', password: 'Pub1ic!Passw0rd-23598' } });
}
answers.set(failing, 'failed');
const publicReset = async (email: string) => {
const res = await manager.handleRequest(
new Request(`${ORIGIN}${BASE}/request-password-reset`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: ORIGIN },
body: JSON.stringify({ email }),
}),
);
return { status: res.status, body: await res.text() };
};
const throughFailing = await publicReset(failing);
const throughFine = await publicReset(fine);
const noAccount = await publicReset('nobody-23598@acme.example.com');
expect(throughFailing.status).toBe(200);
expect(JSON.parse(throughFailing.body)).toEqual({
status: true,
message: 'If this email exists in our system, check your email for the reset link',
});
// No new error surface, and the answer says nothing about whether the
// address has an account or whether its mail went out.
expect(throughFailing).toEqual(throughFine);
expect(throughFailing).toEqual(noAccount);
// Both sends really ran, with the reset copy.
expect(attempts.filter((m) => m.to.address === failing).map((m) => m.template)).toEqual(['auth.password_reset']);
expect(attempts.filter((m) => m.to.address === fine).map((m) => m.template)).toEqual(['auth.password_reset']);
}, 60_000);
});