|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25932] The identity import sizes a generated temporary password to the |
| 5 | + * deployment's password length range, the way create-user's |
| 6 | + * `generatePassword` does: `min(max(16, minPasswordLength), maxPasswordLength)`, |
| 7 | + * read from better-auth's own `$context` (`password.config`), the one reader |
| 8 | + * of that range. |
| 9 | + * |
| 10 | + * Driven on a real stack: real `ObjectQL` over `@objectstack/driver-sql` + |
| 11 | + * better-sqlite3, the objects from this plugin's own manifest, a real |
| 12 | + * `AuthManager` whose `emailAndPassword` range is what the ADR-0069 |
| 13 | + * `auth.password_min_length` / `password_max_length` settings map onto, and |
| 14 | + * the plugin's REAL `POST /admin/import-users` mount, so the pins also hold |
| 15 | + * the route's wiring of the import deps. Only the platform-admin gate's |
| 16 | + * session read is a stand-in (the `admin-import-users-account-invitation` |
| 17 | + * shape). |
| 18 | + * |
| 19 | + * Pinned: |
| 20 | + * ① `temporary` policy under a minimum of 24: the row's password is 24 |
| 21 | + * characters, and the imported user signs in with it. |
| 22 | + * ② `auto` policy, a row with no deliverable channel (no email service |
| 23 | + * wired): the fallback temporary password is sized the same way. |
| 24 | + * ③ The default range: still 16 characters. |
| 25 | + * ④ A maximum below 16 caps the length at that maximum. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 29 | +import { Hono } from 'hono'; |
| 30 | +import { ObjectQL } from '@objectstack/objectql'; |
| 31 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 32 | +import type { PluginContext } from '@objectstack/core'; |
| 33 | +import { AuthManager } from './auth-manager.js'; |
| 34 | +import { AuthPlugin } from './auth-plugin.js'; |
| 35 | +import { authIdentityObjects } from './manifest.js'; |
| 36 | + |
| 37 | +const ORIGIN = 'https://acme.example.com'; |
| 38 | +const BASE = '/api/v1/auth'; |
| 39 | +const SECRET = 'test-secret-at-least-32-chars-long-25932'; |
| 40 | +const ADMIN = { id: 'admin-25932', email: 'ada.admin@acme.example.com' }; |
| 41 | + |
| 42 | +const engines: ObjectQL[] = []; |
| 43 | +afterEach(async () => { |
| 44 | + vi.restoreAllMocks(); |
| 45 | + while (engines.length) { |
| 46 | + try { |
| 47 | + await (engines.pop() as unknown as { destroy?(): Promise<void> })?.destroy?.(); |
| 48 | + } catch { |
| 49 | + /* noop */ |
| 50 | + } |
| 51 | + } |
| 52 | +}); |
| 53 | + |
| 54 | +async function bootEngine(): Promise<ObjectQL> { |
| 55 | + const engine = new ObjectQL(); |
| 56 | + engines.push(engine); |
| 57 | + engine.registerDriver( |
| 58 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), |
| 59 | + true, |
| 60 | + ); |
| 61 | + await engine.init(); |
| 62 | + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); |
| 63 | + await engine.syncSchemas(); |
| 64 | + return engine; |
| 65 | +} |
| 66 | + |
| 67 | +/** A real manager with the given length range; `withEmail` wires a capturing email service. */ |
| 68 | +async function bootManager( |
| 69 | + emailAndPassword: { minPasswordLength?: number; maxPasswordLength?: number }, |
| 70 | + withEmail = true, |
| 71 | +): Promise<AuthManager> { |
| 72 | + const engine = await bootEngine(); |
| 73 | + const manager = new AuthManager({ |
| 74 | + secret: SECRET, |
| 75 | + baseUrl: ORIGIN, |
| 76 | + dataEngine: engine as never, |
| 77 | + plugins: { admin: true }, |
| 78 | + emailAndPassword, |
| 79 | + } as never); |
| 80 | + if (withEmail) manager.setEmailService({ sendTemplate: async () => ({ status: 'sent' }) } as never); |
| 81 | + return manager; |
| 82 | +} |
| 83 | + |
| 84 | +const mockCtx = (): PluginContext => |
| 85 | + ({ |
| 86 | + registerService: vi.fn(), |
| 87 | + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), |
| 88 | + getServices: vi.fn(() => new Map()), |
| 89 | + hook: vi.fn(), |
| 90 | + trigger: vi.fn(), |
| 91 | + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, |
| 92 | + getKernel: vi.fn(), |
| 93 | + }) as unknown as PluginContext; |
| 94 | + |
| 95 | +/** The plugin's REAL route registration; only the gate's session read is a stand-in. */ |
| 96 | +async function mountImport(manager: AuthManager): Promise<Hono> { |
| 97 | + const app = new Hono(); |
| 98 | + const ctx = mockCtx(); |
| 99 | + const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } }); |
| 100 | + await plugin.init(ctx); |
| 101 | + const realApi = await manager.getApi(); |
| 102 | + const gated = new Proxy(manager, { |
| 103 | + get(target, key) { |
| 104 | + if (key === 'getApi') { |
| 105 | + return async () => ({ ...realApi, getSession: async () => ({ user: { ...ADMIN, isPlatformAdmin: true } }) }); |
| 106 | + } |
| 107 | + const value = Reflect.get(target, key, target); |
| 108 | + return typeof value === 'function' ? value.bind(target) : value; |
| 109 | + }, |
| 110 | + }); |
| 111 | + (plugin as unknown as { authManager: unknown }).authManager = gated; |
| 112 | + (plugin as unknown as { |
| 113 | + registerAuthRoutes(server: unknown, ctx: PluginContext): void; |
| 114 | + }).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); |
| 115 | + return app; |
| 116 | +} |
| 117 | + |
| 118 | +/** One email row through the mounted import; answers that row. */ |
| 119 | +async function importOne(app: Hono, email: string, passwordPolicy: 'temporary' | 'auto'): Promise<Record<string, unknown>> { |
| 120 | + const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, { |
| 121 | + method: 'POST', |
| 122 | + headers: { 'content-type': 'application/json', origin: ORIGIN }, |
| 123 | + body: JSON.stringify({ format: 'json', mode: 'insert', passwordPolicy, rows: [{ email, name: 'Imported' }] }), |
| 124 | + }); |
| 125 | + const answer = (await res.json()) as { data?: { summary?: { created?: number; errors?: number }; rows?: Array<Record<string, unknown>> } }; |
| 126 | + expect(res.status, JSON.stringify(answer)).toBe(200); |
| 127 | + expect(answer.data?.summary).toMatchObject({ created: 1, errors: 0 }); |
| 128 | + return answer.data!.rows![0]!; |
| 129 | +} |
| 130 | + |
| 131 | +async function signIn(manager: AuthManager, email: string, password: string): Promise<Response> { |
| 132 | + return manager.handleRequest( |
| 133 | + new Request(`${ORIGIN}${BASE}/sign-in/email`, { |
| 134 | + method: 'POST', |
| 135 | + headers: { 'content-type': 'application/json', origin: ORIGIN }, |
| 136 | + body: JSON.stringify({ email, password }), |
| 137 | + }), |
| 138 | + ); |
| 139 | +} |
| 140 | + |
| 141 | +describe('[#25932] the identity import sizes a temporary password to the deployment length range', () => { |
| 142 | + it('① temporary policy, minimum 24: a 24-character password the user signs in with', async () => { |
| 143 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 144 | + const manager = await bootManager({ minPasswordLength: 24 }); |
| 145 | + const app = await mountImport(manager); |
| 146 | + const email = 'temp-25932@acme.example.com'; |
| 147 | + |
| 148 | + const row = await importOne(app, email, 'temporary'); |
| 149 | + |
| 150 | + expect(row.delivery).toBe('temporary'); |
| 151 | + const password = String(row.temporaryPassword); |
| 152 | + expect(password).toHaveLength(24); |
| 153 | + const res = await signIn(manager, email, password); |
| 154 | + expect(res.status, await res.clone().text()).toBe(200); |
| 155 | + }); |
| 156 | + |
| 157 | + it('② auto policy, a row with no deliverable channel: the fallback password is sized the same way', async () => { |
| 158 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 159 | + const manager = await bootManager({ minPasswordLength: 24 }, false); |
| 160 | + expect(manager.isEmailServiceAvailable(), 'no email service: the row has no deliverable channel').toBe(false); |
| 161 | + const app = await mountImport(manager); |
| 162 | + const email = 'auto-25932@acme.example.com'; |
| 163 | + |
| 164 | + const row = await importOne(app, email, 'auto'); |
| 165 | + |
| 166 | + expect(row.delivery).toBe('temporary'); |
| 167 | + const password = String(row.temporaryPassword); |
| 168 | + expect(password).toHaveLength(24); |
| 169 | + const res = await signIn(manager, email, password); |
| 170 | + expect(res.status, await res.clone().text()).toBe(200); |
| 171 | + }); |
| 172 | + |
| 173 | + it('③ the default range: still 16 characters', async () => { |
| 174 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 175 | + const manager = await bootManager({}); |
| 176 | + const app = await mountImport(manager); |
| 177 | + |
| 178 | + const row = await importOne(app, 'default-25932@acme.example.com', 'temporary'); |
| 179 | + |
| 180 | + expect(String(row.temporaryPassword)).toHaveLength(16); |
| 181 | + }); |
| 182 | + |
| 183 | + it('④ a maximum below 16 caps the length at that maximum', async () => { |
| 184 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 185 | + const manager = await bootManager({ maxPasswordLength: 14 }); |
| 186 | + const app = await mountImport(manager); |
| 187 | + const email = 'capped-25932@acme.example.com'; |
| 188 | + |
| 189 | + const row = await importOne(app, email, 'temporary'); |
| 190 | + |
| 191 | + const password = String(row.temporaryPassword); |
| 192 | + expect(password).toHaveLength(14); |
| 193 | + const res = await signIn(manager, email, password); |
| 194 | + expect(res.status, await res.clone().text()).toBe(200); |
| 195 | + }); |
| 196 | +}); |
0 commit comments