|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23152] The identity import's automatic invitation email carries a link |
| 5 | + * that lands on the Console's set-password page. |
| 6 | + * |
| 7 | + * The import requests a password reset for every email-invited row. better-auth |
| 8 | + * writes the request's `redirectTo` into the emailed link as `callbackURL`, and |
| 9 | + * its `GET /reset-password/:token` refuses an EMPTY one: it redirects to |
| 10 | + * `?error=INVALID_TOKEN` although the token itself still redeems. So the link |
| 11 | + * works only when the request names where it lands. |
| 12 | + * |
| 13 | + * Real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the objects |
| 14 | + * from this plugin's own manifest, and a real `AuthManager` (admin plugin on, |
| 15 | + * an `https` origin that is not a localhost one). better-auth's origin check is |
| 16 | + * switched ON here: it defaults to off under a test runner |
| 17 | + * (`skipOriginCheck: isTest()` in better-auth's context), and with it off the |
| 18 | + * landing would never be judged against the trusted origins a deployment has. |
| 19 | + * |
| 20 | + * Pinned: |
| 21 | + * ① Through the plugin's REAL `POST /admin/import-users` mount, so the one |
| 22 | + * wiring line in `auth-plugin.ts` is under test (the deps member is |
| 23 | + * optional, so a missing wiring line compiles): the emailed link's GET lands |
| 24 | + * on `/_console/reset-password?token=…`, and that token sets the password. |
| 25 | + * A control GET naming a foreign `callbackURL` is refused, which proves the |
| 26 | + * origin check judged the landing. |
| 27 | + * ② `runAdminImportUsers` handed deps WITHOUT `consoleResetPasswordUrl` keeps |
| 28 | + * today's link, an empty `callbackURL`. |
| 29 | + * |
| 30 | + * Only the platform-admin gate's session read is a stand-in (the |
| 31 | + * `principal-less-producers-system-context.test.ts` shape); the gate has its |
| 32 | + * own suites. Every other manager call reaches the real `AuthManager`. |
| 33 | + */ |
| 34 | + |
| 35 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 36 | +import { Hono } from 'hono'; |
| 37 | +import { ObjectQL } from '@objectstack/objectql'; |
| 38 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 39 | +import type { PluginContext } from '@objectstack/core'; |
| 40 | +import { AuthManager } from './auth-manager.js'; |
| 41 | +import { AuthPlugin } from './auth-plugin.js'; |
| 42 | +import { authIdentityObjects } from './manifest.js'; |
| 43 | +import { runAdminImportUsers, type IdentityImportDeps } from './admin-import-users.js'; |
| 44 | + |
| 45 | +const ORIGIN = 'https://acme.example.com'; |
| 46 | +const BASE = '/api/v1/auth'; |
| 47 | +const SECRET = 'test-secret-at-least-32-chars-long-23152'; |
| 48 | +const SET_PASSWORD_PAGE = `${ORIGIN}/_console/reset-password`; |
| 49 | +const ADMIN = { id: 'usr_platform_admin_23152', email: 'admin@acme.example.com' }; |
| 50 | + |
| 51 | +type SentMail = { template: string; to: { address: string }; data: Record<string, unknown> }; |
| 52 | + |
| 53 | +const engines: ObjectQL[] = []; |
| 54 | +afterEach(async () => { |
| 55 | + vi.restoreAllMocks(); |
| 56 | + while (engines.length) { |
| 57 | + try { |
| 58 | + await (engines.pop() as unknown as { destroy?(): Promise<void> })?.destroy?.(); |
| 59 | + } catch { |
| 60 | + /* noop */ |
| 61 | + } |
| 62 | + } |
| 63 | +}); |
| 64 | + |
| 65 | +async function bootEngine(): Promise<ObjectQL> { |
| 66 | + const engine = new ObjectQL(); |
| 67 | + engines.push(engine); |
| 68 | + engine.registerDriver( |
| 69 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), |
| 70 | + true, |
| 71 | + ); |
| 72 | + await engine.init(); |
| 73 | + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); |
| 74 | + await engine.syncSchemas(); |
| 75 | + return engine; |
| 76 | +} |
| 77 | + |
| 78 | +/** A real manager with a capturing email service and the origin check ON. */ |
| 79 | +async function bootManager(): Promise<{ manager: AuthManager; sent: SentMail[] }> { |
| 80 | + const engine = await bootEngine(); |
| 81 | + const manager = new AuthManager({ |
| 82 | + secret: SECRET, |
| 83 | + baseUrl: ORIGIN, |
| 84 | + dataEngine: engine as never, |
| 85 | + plugins: { admin: true }, |
| 86 | + } as never); |
| 87 | + const sent: SentMail[] = []; |
| 88 | + manager.setEmailService({ |
| 89 | + sendTemplate: async (input: SentMail) => { |
| 90 | + sent.push(input); |
| 91 | + return { status: 'sent' }; |
| 92 | + }, |
| 93 | + } as never); |
| 94 | + const authContext = (await manager.getAuthContext()) as { skipOriginCheck: unknown }; |
| 95 | + authContext.skipOriginCheck = false; |
| 96 | + return { manager, sent }; |
| 97 | +} |
| 98 | + |
| 99 | +const mockCtx = (): PluginContext => |
| 100 | + ({ |
| 101 | + registerService: vi.fn(), |
| 102 | + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), |
| 103 | + getServices: vi.fn(() => new Map()), |
| 104 | + hook: vi.fn(), |
| 105 | + trigger: vi.fn(), |
| 106 | + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, |
| 107 | + getKernel: vi.fn(), |
| 108 | + }) as unknown as PluginContext; |
| 109 | + |
| 110 | +/** |
| 111 | + * The plugin's REAL route registration on a real Hono app. Its manager is the |
| 112 | + * real one, with one seam replaced: `getApi()` answers better-auth's real api |
| 113 | + * whose `getSession` reports a platform admin, which is what the gate judges. |
| 114 | + */ |
| 115 | +async function mountImport(manager: AuthManager): Promise<Hono> { |
| 116 | + const app = new Hono(); |
| 117 | + const ctx = mockCtx(); |
| 118 | + const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } }); |
| 119 | + await plugin.init(ctx); |
| 120 | + const realApi = await manager.getApi(); |
| 121 | + const gated = new Proxy(manager, { |
| 122 | + get(target, key) { |
| 123 | + if (key === 'getApi') { |
| 124 | + return async () => ({ ...realApi, getSession: async () => ({ user: { ...ADMIN, isPlatformAdmin: true } }) }); |
| 125 | + } |
| 126 | + const value = Reflect.get(target, key, target); |
| 127 | + return typeof value === 'function' ? value.bind(target) : value; |
| 128 | + }, |
| 129 | + }); |
| 130 | + (plugin as unknown as { authManager: unknown }).authManager = gated; |
| 131 | + (plugin as unknown as { |
| 132 | + registerAuthRoutes(server: unknown, ctx: PluginContext): void; |
| 133 | + }).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); |
| 134 | + return app; |
| 135 | +} |
| 136 | + |
| 137 | +const importBody = (email: string) => |
| 138 | + JSON.stringify({ format: 'json', mode: 'insert', passwordPolicy: 'auto', rows: [{ email, name: 'Invitee' }] }); |
| 139 | + |
| 140 | +/** The invitation email better-auth sent to `address`, once its background send settles. */ |
| 141 | +async function invitationTo(sent: SentMail[], address: string): Promise<SentMail> { |
| 142 | + await vi.waitFor(() => |
| 143 | + expect(sent.some((m) => m.template === 'auth.password_reset' && m.to.address === address)).toBe(true), |
| 144 | + ); |
| 145 | + return sent.find((m) => m.template === 'auth.password_reset' && m.to.address === address)!; |
| 146 | +} |
| 147 | + |
| 148 | +/** GET a link the way a browser follows it, with no redirect followed. */ |
| 149 | +const follow = (manager: AuthManager, link: string) => manager.handleRequest(new Request(link)); |
| 150 | + |
| 151 | +describe('[#23152] the identity import invitation link', () => { |
| 152 | + it('① through the mounted import, lands on the Console set-password page with the origin check on', async () => { |
| 153 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 154 | + const { manager, sent } = await bootManager(); |
| 155 | + const app = await mountImport(manager); |
| 156 | + const email = 'invitee-mounted@acme.example.com'; |
| 157 | + |
| 158 | + const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, { |
| 159 | + method: 'POST', |
| 160 | + headers: { 'content-type': 'application/json', origin: ORIGIN }, |
| 161 | + body: importBody(email), |
| 162 | + }); |
| 163 | + const answer = (await res.json()) as { data?: { rows?: Array<Record<string, unknown>> } }; |
| 164 | + expect(res.status, JSON.stringify(answer)).toBe(200); |
| 165 | + expect(answer.data?.rows?.[0]?.delivery).toBe('email'); |
| 166 | + expect(answer.data?.rows?.[0]?.code, 'the invitation was requested').toBeUndefined(); |
| 167 | + |
| 168 | + const mail = await invitationTo(sent, email); |
| 169 | + const link = new URL(String(mail.data.resetUrl)); |
| 170 | + const token = String(mail.data.token); |
| 171 | + expect(link.origin + link.pathname).toBe(`${ORIGIN}${BASE}/reset-password/${token}`); |
| 172 | + |
| 173 | + // Where the invitee lands first: before the fix this read |
| 174 | + // `${ORIGIN}${BASE}/error?error=INVALID_TOKEN`. |
| 175 | + const landed = await follow(manager, link.href); |
| 176 | + expect(landed.status).toBe(302); |
| 177 | + expect(landed.headers.get('location')).toBe(`${SET_PASSWORD_PAGE}?token=${token}`); |
| 178 | + expect(link.searchParams.get('callbackURL')).toBe(SET_PASSWORD_PAGE); |
| 179 | + |
| 180 | + // Control: the same token with a callbackURL on a foreign origin is refused |
| 181 | + // rather than redirected, so the landing above was judged, not waved through. |
| 182 | + const foreign = new URL(link.href); |
| 183 | + foreign.searchParams.set('callbackURL', 'https://elsewhere.example.net/_console/reset-password'); |
| 184 | + const refused = await follow(manager, foreign.href); |
| 185 | + expect(refused.status).toBe(403); |
| 186 | + expect(refused.headers.get('location')).toBeNull(); |
| 187 | + |
| 188 | + // The token the page receives sets the invitee's password. |
| 189 | + const reset = await manager.handleRequest( |
| 190 | + new Request(`${ORIGIN}${BASE}/reset-password`, { |
| 191 | + method: 'POST', |
| 192 | + headers: { 'content-type': 'application/json', origin: ORIGIN }, |
| 193 | + body: JSON.stringify({ token, newPassword: 'Inv1ted!Passw0rd-23152' }), |
| 194 | + }), |
| 195 | + ); |
| 196 | + expect(reset.status, await reset.clone().text()).toBe(200); |
| 197 | + }, 120_000); |
| 198 | + |
| 199 | + it('② a caller whose deps carry no consoleResetPasswordUrl keeps today\'s link, an empty callbackURL', async () => { |
| 200 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 201 | + const { manager, sent } = await bootManager(); |
| 202 | + const email = 'invitee-direct@acme.example.com'; |
| 203 | + const deps: IdentityImportDeps = { |
| 204 | + getAuthApi: () => manager.getApi(), |
| 205 | + getDataEngine: () => manager.getDataEngine() as never, |
| 206 | + phoneNumberEnabled: () => manager.isPhoneNumberEnabled(), |
| 207 | + emailServiceAvailable: () => manager.isEmailServiceAvailable(), |
| 208 | + smsInviteAvailable: () => false, |
| 209 | + sendInviteSms: (phone: string) => manager.sendPhoneInviteSms(phone), |
| 210 | + noteMustChangePasswordIssued: () => manager.noteMustChangePasswordIssued(), |
| 211 | + }; |
| 212 | + |
| 213 | + const res = await runAdminImportUsers( |
| 214 | + deps, |
| 215 | + new Request(`${ORIGIN}${BASE}/admin/import-users`, { |
| 216 | + method: 'POST', |
| 217 | + headers: { 'content-type': 'application/json' }, |
| 218 | + body: importBody(email), |
| 219 | + }), |
| 220 | + ADMIN, |
| 221 | + ); |
| 222 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 223 | + |
| 224 | + const mail = await invitationTo(sent, email); |
| 225 | + expect(String(mail.data.resetUrl)).toBe(`${ORIGIN}${BASE}/reset-password/${String(mail.data.token)}?callbackURL=`); |
| 226 | + }, 120_000); |
| 227 | +}); |
0 commit comments