|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `createIdentityObjectsPlugin()`: plugin-auth's identity objects for a kernel |
| 5 | + * that mounts ObjectQL without `AuthPlugin`, the reduced kernel an app's or a |
| 6 | + * plugin's own test suite boots. |
| 7 | + * |
| 8 | + * Pins: |
| 9 | + * 1. ObjectQL + an app + this plugin: a `sys_user` fixture inserts, and the |
| 10 | + * registered set is plugin-auth's list under plugin-auth's package id. |
| 11 | + * Without the plugin the same insert is refused `OBJECT_NOT_FOUND` (control). |
| 12 | + * 2. Adding `SecurityPlugin`: the kernel boots and permissions resolve from |
| 13 | + * `sys_member` through `@objectstack/core`'s `resolveUserAuthzGrants`. |
| 14 | + * 3. `SecurityPlugin` without the identity objects is refused at boot by name |
| 15 | + * (`plugin-security`'s own suite pins the refusal; here, the remedy it names |
| 16 | + * is this export). |
| 17 | + * 4. The full kernel is unchanged: `AuthPlugin` registers the same identity |
| 18 | + * manifest, from the same builder, in its one registration. |
| 19 | + * |
| 20 | + * "An app" is a plugin that registers its manifest through the `manifest` |
| 21 | + * service in `init()`, which is what `AppPlugin.init()` does. `AppPlugin` itself |
| 22 | + * lives in `@objectstack/runtime`, which depends on this package, so this suite |
| 23 | + * cannot import it without a workspace cycle. |
| 24 | + */ |
| 25 | + |
| 26 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 27 | +import { |
| 28 | + ObjectKernel, |
| 29 | + resolvePluginOrder, |
| 30 | + resolveUserAuthzGrants, |
| 31 | + type OrderablePlugin, |
| 32 | + type Plugin, |
| 33 | + type PluginContext, |
| 34 | +} from '@objectstack/core'; |
| 35 | +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; |
| 36 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 37 | +import { SecurityPlugin } from '@objectstack/plugin-security'; |
| 38 | +import { Field } from '@objectstack/spec/data'; |
| 39 | +import { |
| 40 | + IDENTITY_OBJECTS_PLUGIN_NAME, |
| 41 | + IdentityObjectsPlugin, |
| 42 | + createIdentityObjectsPlugin, |
| 43 | +} from './identity-objects-plugin.js'; |
| 44 | +import { AUTH_PLUGIN_ID, authIdentityManifest, authIdentityObjects, authObjectExtensions } from './manifest.js'; |
| 45 | +import { AuthPlugin } from './auth-plugin.js'; |
| 46 | + |
| 47 | +const SYS = { isSystem: true } as const; |
| 48 | + |
| 49 | +/** Publishes an in-memory SQLite driver the way a datasource plugin does. */ |
| 50 | +function sqliteDriverPlugin(): Plugin { |
| 51 | + return { |
| 52 | + name: 'test.driver.sqlite', |
| 53 | + type: 'standard', |
| 54 | + version: '1.0.0', |
| 55 | + async init(ctx: PluginContext) { |
| 56 | + ctx.registerService( |
| 57 | + 'driver.default', |
| 58 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), |
| 59 | + ); |
| 60 | + }, |
| 61 | + }; |
| 62 | +} |
| 63 | + |
| 64 | +/** An app registering its manifest through the `manifest` service, as `AppPlugin.init()` does. */ |
| 65 | +function appPlugin(): Plugin { |
| 66 | + return { |
| 67 | + name: 'com.example.kit-app', |
| 68 | + type: 'app', |
| 69 | + version: '1.0.0', |
| 70 | + dependencies: ['com.objectstack.engine.objectql'], |
| 71 | + async init(ctx: PluginContext) { |
| 72 | + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ |
| 73 | + id: 'com.example.kit-app', |
| 74 | + version: '1.0.0', |
| 75 | + type: 'app', |
| 76 | + name: 'Kit App', |
| 77 | + objects: [{ name: 'kit_account', label: 'Account', fields: { name: Field.text({ label: 'Name' }) } }], |
| 78 | + }); |
| 79 | + }, |
| 80 | + }; |
| 81 | +} |
| 82 | + |
| 83 | +describe('createIdentityObjectsPlugin() — identity objects for a kernel without AuthPlugin', () => { |
| 84 | + let kernel: ObjectKernel | undefined; |
| 85 | + |
| 86 | + afterEach(async () => { |
| 87 | + try { |
| 88 | + await kernel?.shutdown(); |
| 89 | + } catch { |
| 90 | + /* a refused boot leaves the kernel stopped */ |
| 91 | + } |
| 92 | + kernel = undefined; |
| 93 | + }); |
| 94 | + |
| 95 | + async function boot(plugins: Plugin[]): Promise<ObjectQL> { |
| 96 | + kernel = new ObjectKernel({ logger: { level: 'silent' } }); |
| 97 | + await kernel.use(sqliteDriverPlugin()); |
| 98 | + await kernel.use(new ObjectQLPlugin()); |
| 99 | + await kernel.use(appPlugin()); |
| 100 | + for (const p of plugins) await kernel.use(p); |
| 101 | + await kernel.bootstrap(); |
| 102 | + return kernel.getService<ObjectQL>('objectql'); |
| 103 | + } |
| 104 | + |
| 105 | + it('1. registers plugin-auth\'s list under plugin-auth\'s package id, and a sys_user fixture inserts', async () => { |
| 106 | + const ql = await boot([createIdentityObjectsPlugin()]); |
| 107 | + |
| 108 | + const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort(); |
| 109 | + expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort()); |
| 110 | + |
| 111 | + await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS }); |
| 112 | + const row = await ql.findOne('sys_user', { where: { id: 'usr_kit' }, context: SYS }); |
| 113 | + expect(row?.email).toBe('kit@example.com'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('1 (control). without it, the same sys_user insert is refused OBJECT_NOT_FOUND', async () => { |
| 117 | + const ql = await boot([]); |
| 118 | + |
| 119 | + const refusal = await ql |
| 120 | + .insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS }) |
| 121 | + .then(() => undefined, (err: unknown) => err as { code?: string; status?: number }); |
| 122 | + expect(refusal).toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); |
| 123 | + }); |
| 124 | + |
| 125 | + it('2. with SecurityPlugin added, the kernel boots and permissions resolve from sys_member', async () => { |
| 126 | + const ql = await boot([createIdentityObjectsPlugin(), new SecurityPlugin()]); |
| 127 | + |
| 128 | + await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS }); |
| 129 | + await ql.insert('sys_organization', { id: 'org_kit', name: 'Kit Org' }, { context: SYS }); |
| 130 | + await ql.insert( |
| 131 | + 'sys_member', |
| 132 | + { id: 'mem_kit', user_id: 'usr_kit', organization_id: 'org_kit', role: 'admin' }, |
| 133 | + { context: SYS }, |
| 134 | + ); |
| 135 | + |
| 136 | + const grants = await resolveUserAuthzGrants(ql, 'usr_kit', { tenantId: 'org_kit' }); |
| 137 | + expect(grants.positions).toContain('org_admin'); |
| 138 | + expect(grants.accessible_org_ids).toEqual(['org_kit']); |
| 139 | + expect(grants.email).toBe('kit@example.com'); |
| 140 | + }); |
| 141 | + |
| 142 | + it('3. SecurityPlugin without them is refused at boot, and the refusal names this export', async () => { |
| 143 | + const refusal = await boot([new SecurityPlugin()]).then(() => undefined, (err: unknown) => err as Error); |
| 144 | + |
| 145 | + expect(refusal?.name).toBe('AuthzIdentityObjectsMissingError'); |
| 146 | + expect(refusal?.message).toContain('createIdentityObjectsPlugin()'); |
| 147 | + expect(refusal?.message).toContain('@objectstack/plugin-auth'); |
| 148 | + }); |
| 149 | + |
| 150 | + it('4. the full kernel (AuthPlugin, no preset) boots SecurityPlugin unchanged', async () => { |
| 151 | + const ql = await boot([ |
| 152 | + new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }), |
| 153 | + new SecurityPlugin(), |
| 154 | + ]); |
| 155 | + |
| 156 | + const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort(); |
| 157 | + expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort()); |
| 158 | + }); |
| 159 | +}); |
| 160 | + |
| 161 | +describe('one list: AuthPlugin and IdentityObjectsPlugin register the same identity manifest', () => { |
| 162 | + /** A context whose `manifest.register` records what was registered. */ |
| 163 | + function capturingContext(services: Record<string, unknown> = {}) { |
| 164 | + const registered: Array<Record<string, unknown>> = []; |
| 165 | + const ctx = { |
| 166 | + registerService: vi.fn(), |
| 167 | + getService: vi.fn((name: string) => { |
| 168 | + if (name === 'manifest') return { register: (m: Record<string, unknown>) => registered.push(m) }; |
| 169 | + if (name in services) return services[name]; |
| 170 | + throw new Error(`service not registered: ${name}`); |
| 171 | + }), |
| 172 | + getServices: vi.fn(() => new Map()), |
| 173 | + hook: vi.fn(), |
| 174 | + trigger: vi.fn(), |
| 175 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 176 | + getKernel: vi.fn(), |
| 177 | + } as unknown as PluginContext; |
| 178 | + return { ctx, registered }; |
| 179 | + } |
| 180 | + |
| 181 | + it('4. AuthPlugin\'s one registration carries the identity manifest unchanged (the full kernel)', async () => { |
| 182 | + const { ctx, registered } = capturingContext({ data: undefined }); |
| 183 | + await new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }).init(ctx); |
| 184 | + |
| 185 | + expect(registered).toHaveLength(1); |
| 186 | + const [manifest] = registered; |
| 187 | + expect(manifest).toMatchObject(authIdentityManifest()); |
| 188 | + expect(manifest.objects).toBe(authIdentityObjects); |
| 189 | + expect(manifest.objectExtensions).toBe(authObjectExtensions); |
| 190 | + expect(manifest.id).toBe(AUTH_PLUGIN_ID); |
| 191 | + // The rest of AuthPlugin's manifest is still there. |
| 192 | + expect(Array.isArray(manifest.pages) && manifest.pages.length).toBeGreaterThan(0); |
| 193 | + expect(Array.isArray(manifest.dashboards) && manifest.dashboards.length).toBeGreaterThan(0); |
| 194 | + }); |
| 195 | + |
| 196 | + it('4. IdentityObjectsPlugin registers exactly that manifest, and honours the datasource override as AuthPlugin does', async () => { |
| 197 | + const plain = capturingContext(); |
| 198 | + await createIdentityObjectsPlugin().init(plain.ctx); |
| 199 | + expect(plain.registered).toEqual([authIdentityManifest()]); |
| 200 | + expect(plain.registered[0].objects).toBe(authIdentityObjects); |
| 201 | + |
| 202 | + const kit = capturingContext(); |
| 203 | + await createIdentityObjectsPlugin({ manifestDatasource: 'default' }).init(kit.ctx); |
| 204 | + const full = capturingContext({ data: undefined }); |
| 205 | + await new AuthPlugin({ |
| 206 | + secret: 'test-secret-at-least-32-chars-long', |
| 207 | + baseUrl: 'http://localhost:3000', |
| 208 | + manifestDatasource: 'default', |
| 209 | + }).init(full.ctx); |
| 210 | + expect(kit.registered[0].defaultDatasource).toBe('default'); |
| 211 | + expect(full.registered[0].defaultDatasource).toBe('default'); |
| 212 | + }); |
| 213 | +}); |
| 214 | + |
| 215 | +describe('not beside AuthPlugin', () => { |
| 216 | + it('orders AuthPlugin ahead when both are composed, whatever the insertion order', () => { |
| 217 | + const engine: OrderablePlugin = { name: 'com.objectstack.engine.objectql' }; |
| 218 | + const identity = createIdentityObjectsPlugin() as unknown as OrderablePlugin; |
| 219 | + const auth = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long' }) as unknown as OrderablePlugin; |
| 220 | + const order = resolvePluginOrder( |
| 221 | + new Map([engine, identity, auth].map((p) => [p.name, p])), |
| 222 | + ).map((p) => p.name); |
| 223 | + |
| 224 | + expect(order.indexOf('com.objectstack.auth')).toBeLessThan(order.indexOf(IDENTITY_OBJECTS_PLUGIN_NAME)); |
| 225 | + }); |
| 226 | + |
| 227 | + it('refuses to register when AuthPlugin has registered the auth service', async () => { |
| 228 | + const register = vi.fn(); |
| 229 | + const ctx = { |
| 230 | + getService: vi.fn((name: string) => { |
| 231 | + if (name === 'auth') return {}; |
| 232 | + if (name === 'manifest') return { register }; |
| 233 | + throw new Error(`service not registered: ${name}`); |
| 234 | + }), |
| 235 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 236 | + } as unknown as PluginContext; |
| 237 | + |
| 238 | + const refusal = await new IdentityObjectsPlugin().init(ctx).then(() => undefined, (err: unknown) => err as Error); |
| 239 | + expect(refusal?.message).toContain(IDENTITY_OBJECTS_PLUGIN_NAME); |
| 240 | + expect(refusal?.message).toContain('AuthPlugin'); |
| 241 | + expect(register).not.toHaveBeenCalled(); |
| 242 | + }); |
| 243 | +}); |
0 commit comments