Skip to content

Commit 1a51ee2

Browse files
committed
feat(auth): identity bulk import endpoint POST /admin/import-users (#2766 V2)
Dedicated sys_user import (re-scoped #2758) — the generic /data import path writes through ObjectQL, bypassing better-auth hashing and never creating the sys_account credential row, so it stays closed for identity. This endpoint reuses the generic import FRAMEWORK (prepareImportRequest payload parsing: rows[]/csv/xlsxBase64 + mapping; runImport row engine: dryRun, upsert matching, row results) with an identity write protocol: - createData drives auth.api.createUser per row (scrypt + credential account); no bulk primitive on purpose. - passwordPolicy 'invite': rows must carry a real email (row-level INVITE_REQUIRES_EMAIL at validation/dryRun time; request-level 400 when no EmailService is wired); accounts are created with a throwaway password and a set-your-password reset email is requested per created row. Email failure marks the row INVITE_EMAIL_FAILED — created, not rolled back. - passwordPolicy 'temporary': generated password per row, returned ONCE in the response; must_change_password stamped (403 PASSWORD_EXPIRED gate from V1). Red-line tests assert no password reaches audit rows, logs, or any persisted surface. - mode insert|upsert, matchBy email|phone (phone requires the V1.5 phoneNumber plugin); upsert updates touch profile fields only (name/phone_number/role) — never email, never credentials, so a re-imported CSV can't silently reset existing users' passwords; blank match keys skip instead of creating duplicates. - Phone-only rows (temporary policy) get placeholder emails; imported password columns are ignored (pre-hashed migration is out of scope). - Sync only, <=500 rows/request (scrypt ~100ms/row); async:true returns ASYNC_NOT_SUPPORTED with batching guidance — an async job is a persistence surface and cannot carry temporary passwords; tracked as a follow-up. No undo by design (undo == bulk-deleting users). - Run-level sys_audit_log row (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to), password-free. plugin-auth now depends on @objectstack/rest (no cycle; rest does not depend on plugin-auth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
1 parent d0fefbe commit 1a51ee2

9 files changed

Lines changed: 765 additions & 3 deletions

File tree

‎packages/plugins/plugin-auth/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@noble/hashes": "^2.2.0",
2626
"@objectstack/core": "workspace:*",
2727
"@objectstack/platform-objects": "workspace:*",
28+
"@objectstack/rest": "workspace:*",
2829
"@objectstack/spec": "workspace:*",
2930
"@objectstack/types": "workspace:*",
3031
"better-auth": "^1.6.23",
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { runAdminImportUsers, IMPORT_USERS_MAX_ROWS, type IdentityImportDeps } from './admin-import-users.js';
5+
import type { AdminActor } from './admin-user-endpoints.js';
6+
7+
const ACTOR: AdminActor = { id: 'admin-1', email: 'admin@example.com' };
8+
9+
function makeRequest(body: unknown): Request {
10+
return new Request('http://localhost/api/v1/auth/admin/import-users', {
11+
method: 'POST',
12+
headers: { 'content-type': 'application/json' },
13+
body: JSON.stringify(body),
14+
});
15+
}
16+
17+
function makeDeps(opts: {
18+
existingUsers?: Array<Record<string, any>>;
19+
phoneEnabled?: boolean;
20+
emailAvailable?: boolean;
21+
resetFails?: boolean;
22+
} = {}) {
23+
const existing = opts.existingUsers ?? [];
24+
let nextId = 1;
25+
const createUser = vi.fn(async ({ body }: any) => ({
26+
user: { id: `u-${nextId++}`, email: body.email, name: body.name },
27+
}));
28+
const requestPasswordReset = vi.fn(async () => {
29+
if (opts.resetFails) throw new Error('smtp down');
30+
return { status: true };
31+
});
32+
const find = vi.fn(async (_obj: string, q: any) => {
33+
const where = q?.where ?? {};
34+
return existing.filter((u) => Object.entries(where).every(([k, v]) => u[k] === v));
35+
});
36+
const update = vi.fn(async () => ({}));
37+
const insert = vi.fn(async () => ({}));
38+
const warn = vi.fn();
39+
const noteMustChangePasswordIssued = vi.fn();
40+
const deps: IdentityImportDeps = {
41+
getAuthApi: async () => ({ createUser, requestPasswordReset }),
42+
getDataEngine: () => ({ find, update, insert }),
43+
phoneNumberEnabled: () => opts.phoneEnabled ?? false,
44+
emailServiceAvailable: () => opts.emailAvailable ?? true,
45+
noteMustChangePasswordIssued,
46+
logger: { warn },
47+
};
48+
return { deps, createUser, requestPasswordReset, find, update, insert, warn, noteMustChangePasswordIssued };
49+
}
50+
51+
/** Red line: no generated password may reach any persistence/log surface. */
52+
function expectNoPasswordLeak(m: ReturnType<typeof makeDeps>, passwords: string[]) {
53+
const surfaces = JSON.stringify([m.insert.mock.calls, m.update.mock.calls, m.warn.mock.calls]);
54+
for (const pw of passwords) {
55+
if (typeof pw === 'string' && pw.length > 0) expect(surfaces).not.toContain(pw);
56+
}
57+
}
58+
59+
describe('runAdminImportUsers — request validation', () => {
60+
it('rejects a missing/invalid passwordPolicy', async () => {
61+
const m = makeDeps();
62+
const res = await runAdminImportUsers(m.deps, makeRequest({ format: 'json', rows: [] }), ACTOR);
63+
expect(res.status).toBe(400);
64+
expect(m.createUser).not.toHaveBeenCalled();
65+
});
66+
67+
it('rejects matchBy phone when the phoneNumber plugin is off', async () => {
68+
const m = makeDeps({ phoneEnabled: false });
69+
const res = await runAdminImportUsers(
70+
m.deps,
71+
makeRequest({ passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json', rows: [] }),
72+
ACTOR,
73+
);
74+
expect(res.status).toBe(400);
75+
expect(res.body.error?.code).toBe('PHONE_NOT_ENABLED');
76+
});
77+
78+
it('rejects the invite policy without an email service', async () => {
79+
const m = makeDeps({ emailAvailable: false });
80+
const res = await runAdminImportUsers(
81+
m.deps,
82+
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@b.co' }] }),
83+
ACTOR,
84+
);
85+
expect(res.status).toBe(400);
86+
expect(res.body.error?.code).toBe('EMAIL_SERVICE_REQUIRED');
87+
expect(m.createUser).not.toHaveBeenCalled();
88+
});
89+
90+
it('rejects async: true explicitly (not silently)', async () => {
91+
const m = makeDeps();
92+
const res = await runAdminImportUsers(
93+
m.deps,
94+
makeRequest({ passwordPolicy: 'temporary', async: true, format: 'json', rows: [{ email: 'a@b.co' }] }),
95+
ACTOR,
96+
);
97+
expect(res.status).toBe(400);
98+
expect(res.body.error?.code).toBe('ASYNC_NOT_SUPPORTED');
99+
});
100+
101+
it('rejects payloads above the row cap with 413', async () => {
102+
const m = makeDeps();
103+
const rows = Array.from({ length: IMPORT_USERS_MAX_ROWS + 1 }, (_, i) => ({ email: `u${i}@x.co` }));
104+
const res = await runAdminImportUsers(
105+
m.deps,
106+
makeRequest({ passwordPolicy: 'temporary', format: 'json', rows }),
107+
ACTOR,
108+
);
109+
expect(res.status).toBe(413);
110+
expect(m.createUser).not.toHaveBeenCalled();
111+
});
112+
});
113+
114+
describe('runAdminImportUsers — row validation (also on dryRun)', () => {
115+
it('flags invite rows without a real email as INVITE_REQUIRES_EMAIL', async () => {
116+
const m = makeDeps({ phoneEnabled: true });
117+
const res = await runAdminImportUsers(
118+
m.deps,
119+
makeRequest({
120+
passwordPolicy: 'invite', dryRun: true, format: 'json',
121+
rows: [
122+
{ email: 'ok@x.co', name: 'OK' },
123+
{ phone_number: '+8613800000000', name: 'PhoneOnly' },
124+
{ name: 'Nobody' },
125+
{ email: 'u-aaaaaaaaaaaaaaaaaaaa@placeholder.invalid' },
126+
{ email: 'not-an-email' },
127+
],
128+
}),
129+
ACTOR,
130+
);
131+
expect(res.status).toBe(200);
132+
const rows = (res.body.data as any).rows;
133+
expect(rows[0].action).toBe('created'); // dryRun projection
134+
expect(rows[1].code).toBe('INVITE_REQUIRES_EMAIL');
135+
expect(rows[2].code).toBe('NO_IDENTITY');
136+
expect(rows[3].code).toBe('INVALID_EMAIL');
137+
expect(rows[4].code).toBe('INVALID_EMAIL');
138+
expect((res.body.data as any).summary.errors).toBe(4);
139+
// dryRun writes nothing
140+
expect(m.createUser).not.toHaveBeenCalled();
141+
expect(m.update).not.toHaveBeenCalled();
142+
expect(m.insert).not.toHaveBeenCalled();
143+
});
144+
145+
it('flags phone columns when the plugin is off, and bad phone formats', async () => {
146+
const m = makeDeps({ phoneEnabled: false });
147+
const res = await runAdminImportUsers(
148+
m.deps,
149+
makeRequest({
150+
passwordPolicy: 'temporary', dryRun: true, format: 'json',
151+
rows: [{ phone_number: '+8613800000000' }],
152+
}),
153+
ACTOR,
154+
);
155+
expect((res.body.data as any).rows[0].code).toBe('PHONE_NOT_ENABLED');
156+
157+
const m2 = makeDeps({ phoneEnabled: true });
158+
const res2 = await runAdminImportUsers(
159+
m2.deps,
160+
makeRequest({
161+
passwordPolicy: 'temporary', dryRun: true, format: 'json',
162+
rows: [{ phone_number: 'junk' }],
163+
}),
164+
ACTOR,
165+
);
166+
expect((res2.body.data as any).rows[0].code).toBe('INVALID_PHONE');
167+
});
168+
});
169+
170+
describe('runAdminImportUsers — temporary policy', () => {
171+
it('creates each row through better-auth, stamps must_change_password, returns per-row temp passwords once', async () => {
172+
const m = makeDeps({ phoneEnabled: true });
173+
const res = await runAdminImportUsers(
174+
m.deps,
175+
makeRequest({
176+
passwordPolicy: 'temporary', format: 'json',
177+
rows: [
178+
{ email: 'a@x.co', name: 'A' },
179+
{ phone_number: '+8613800000001', name: 'B' },
180+
],
181+
}),
182+
ACTOR,
183+
);
184+
expect(res.status).toBe(200);
185+
const data = res.body.data as any;
186+
expect(data.summary.created).toBe(2);
187+
expect(m.createUser).toHaveBeenCalledTimes(2);
188+
189+
// Row 1: real email; row 2: placeholder that never contains the phone.
190+
const sent1 = m.createUser.mock.calls[0][0].body;
191+
const sent2 = m.createUser.mock.calls[1][0].body;
192+
expect(sent1.email).toBe('a@x.co');
193+
expect(typeof sent1.password).toBe('string');
194+
expect(sent2.email).toMatch(/@placeholder\.invalid$/);
195+
expect(sent2.email).not.toContain('138');
196+
expect(sent2.data.phoneNumber).toBe('+8613800000001');
197+
198+
// must_change_password stamped per created user + gate cache primed.
199+
const stamps = m.update.mock.calls.filter((c) => c[1]?.must_change_password === true);
200+
expect(stamps.length).toBe(2);
201+
expect(m.noteMustChangePasswordIssued).toHaveBeenCalled();
202+
203+
// Temp passwords: response-only, one per row, and they are the ones sent
204+
// to better-auth for hashing.
205+
const pw1 = data.rows[0].temporaryPassword;
206+
const pw2 = data.rows[1].temporaryPassword;
207+
expect(pw1).toBe(sent1.password);
208+
expect(pw2).toBe(sent2.password);
209+
expectNoPasswordLeak(m, [pw1, pw2]);
210+
211+
// No invitation emails under the temporary policy.
212+
expect(m.requestPasswordReset).not.toHaveBeenCalled();
213+
214+
// Run-level audit row without password material.
215+
const audit = m.insert.mock.calls.find((c) => c[0] === 'sys_audit_log');
216+
expect(audit).toBeTruthy();
217+
const meta = JSON.parse(audit![1].metadata);
218+
expect(meta.event).toBe('user.import_run');
219+
expect(meta.created).toBe(2);
220+
});
221+
});
222+
223+
describe('runAdminImportUsers — invite policy', () => {
224+
it('creates with a throwaway password and requests a reset email per created row', async () => {
225+
const m = makeDeps();
226+
const res = await runAdminImportUsers(
227+
m.deps,
228+
makeRequest({
229+
passwordPolicy: 'invite', format: 'json',
230+
rows: [{ email: 'a@x.co' }, { email: 'b@x.co' }],
231+
}),
232+
ACTOR,
233+
);
234+
const data = res.body.data as any;
235+
expect(data.summary.created).toBe(2);
236+
expect(m.requestPasswordReset).toHaveBeenCalledTimes(2);
237+
expect(m.requestPasswordReset.mock.calls.map((c) => c[0].body.email).sort()).toEqual(['a@x.co', 'b@x.co']);
238+
// The throwaway password is not returned to the caller.
239+
expect(data.rows[0].temporaryPassword).toBeUndefined();
240+
// No must-change stamp for invite (users set their own password).
241+
expect(m.update.mock.calls.filter((c) => c[1]?.must_change_password === true).length).toBe(0);
242+
expectNoPasswordLeak(m, m.createUser.mock.calls.map((c) => c[0].body.password));
243+
});
244+
245+
it('keeps the row created (with INVITE_EMAIL_FAILED) when the email fails — no rollback', async () => {
246+
const m = makeDeps({ resetFails: true });
247+
const res = await runAdminImportUsers(
248+
m.deps,
249+
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@x.co' }] }),
250+
ACTOR,
251+
);
252+
const row = (res.body.data as any).rows[0];
253+
expect(row.ok).toBe(true);
254+
expect(row.action).toBe('created');
255+
expect(row.code).toBe('INVITE_EMAIL_FAILED');
256+
expect((res.body.data as any).summary.created).toBe(1);
257+
});
258+
});
259+
260+
describe('runAdminImportUsers — upsert', () => {
261+
it('matches by email: updates profile fields only, never credentials or email', async () => {
262+
const m = makeDeps({
263+
existingUsers: [{ id: 'u-old', email: 'a@x.co', name: 'Old Name' }],
264+
});
265+
const res = await runAdminImportUsers(
266+
m.deps,
267+
makeRequest({
268+
passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'email', format: 'json',
269+
rows: [
270+
{ email: 'a@x.co', name: 'New Name', password: 'Injected1!' }, // password column must be ignored
271+
{ email: 'new@x.co', name: 'Fresh' },
272+
],
273+
}),
274+
ACTOR,
275+
);
276+
const data = res.body.data as any;
277+
expect(data.summary.updated).toBe(1);
278+
expect(data.summary.created).toBe(1);
279+
280+
// The existing user was patched with name only — no email, no password.
281+
const patch = m.update.mock.calls.find((c) => c[1]?.id === 'u-old');
282+
expect(patch).toBeTruthy();
283+
expect(patch![1]).toEqual({ id: 'u-old', name: 'New Name' });
284+
285+
// The updated row never went through createUser; the new row did.
286+
expect(m.createUser).toHaveBeenCalledTimes(1);
287+
expect(m.createUser.mock.calls[0][0].body.email).toBe('new@x.co');
288+
289+
// Updated (existing) users never get a temporary password back.
290+
expect(data.rows[0].temporaryPassword).toBeUndefined();
291+
expect(typeof data.rows[1].temporaryPassword).toBe('string');
292+
});
293+
294+
it('matches by phone_number when enabled', async () => {
295+
const m = makeDeps({
296+
phoneEnabled: true,
297+
existingUsers: [{ id: 'u-p', email: 'p@x.co', phone_number: '+8613800000009', name: 'P' }],
298+
});
299+
const res = await runAdminImportUsers(
300+
m.deps,
301+
makeRequest({
302+
passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json',
303+
rows: [{ phone_number: '+86 138 0000 0009', name: 'P2' }],
304+
}),
305+
ACTOR,
306+
);
307+
const data = res.body.data as any;
308+
expect(data.summary.updated).toBe(1);
309+
expect(m.find).toHaveBeenCalledWith('sys_user', expect.objectContaining({
310+
where: { phone_number: '+8613800000009' },
311+
}));
312+
});
313+
314+
it('skips upsert rows whose match key is blank instead of creating duplicates', async () => {
315+
const m = makeDeps({ phoneEnabled: true });
316+
const res = await runAdminImportUsers(
317+
m.deps,
318+
makeRequest({
319+
passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json',
320+
rows: [{ email: 'email-only@x.co', name: 'NoPhone' }],
321+
}),
322+
ACTOR,
323+
);
324+
const row = (res.body.data as any).rows[0];
325+
expect(row.action).toBe('skipped');
326+
expect(m.createUser).not.toHaveBeenCalled();
327+
});
328+
});
329+
330+
describe('runAdminImportUsers — CSV payloads', () => {
331+
it('accepts the same CSV shape as the generic import route', async () => {
332+
const m = makeDeps();
333+
const csv = 'email,name\nc1@x.co,C One\nc2@x.co,C Two\n';
334+
const res = await runAdminImportUsers(
335+
m.deps,
336+
makeRequest({ passwordPolicy: 'temporary', csv }),
337+
ACTOR,
338+
);
339+
const data = res.body.data as any;
340+
expect(data.summary.created).toBe(2);
341+
expect(m.createUser.mock.calls.map((c) => c[0].body.email).sort()).toEqual(['c1@x.co', 'c2@x.co']);
342+
});
343+
});

0 commit comments

Comments
 (0)