|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `auth.audience_*` — the console switch surface for the audience posture |
| 5 | + * (#11768, consuming the #11739 / PR #11767 contract). |
| 6 | + * |
| 7 | + * The three settings keys (`audience_posture`, `audience_allowed_email_domains`, |
| 8 | + * `audience_self_registration_permission_set`) are ONE atomic declaration that |
| 9 | + * `bindAuthSettings` maps to ONE `applyConfigPatch({ audience })` — the patch |
| 10 | + * replaces the whole audience object and validates the MERGED result |
| 11 | + * (`assertAudienceConfig`), so the settings channel can never reach a posture |
| 12 | + * the boot-config channel could not. |
| 13 | + * |
| 14 | + * The load-bearing pins here are the REFUSALS (#5152: explicit-only, loud |
| 15 | + * refusal, never coercion). A suite that only sets valid postures cannot tell |
| 16 | + * an enforcing binding from a pass-through, so each ruled invariant is driven |
| 17 | + * through the settings channel to its refusal: |
| 18 | + * |
| 19 | + * - empty domain list under `email_domain`; |
| 20 | + * - missing / forbidden (`admin_full_access`) self-registration permission set; |
| 21 | + * - posture ≠ `invite_only` with verification explicitly off. |
| 22 | + * |
| 23 | + * Envelope note: refusals at THIS seam are log-line refusals (the binding runs |
| 24 | + * inside `applySettings`, not on an HTTP surface), so the assertions pin the |
| 25 | + * logger level + message content. The settings channel's envelope-carrying |
| 26 | + * refusal (`SETTINGS_VALIDATION` + `invalid_option` on `setMany`) is pinned in |
| 27 | + * `service-settings`' auth.manifest.test.ts, and the admission-time envelope |
| 28 | + * (403 + `AUTH_CONFIG_ERROR`/`SELF_REGISTRATION_CLOSED`) is pinned in |
| 29 | + * audience-posture.test.ts — the settings channel converges on the same |
| 30 | + * `getAudience()` accessor those tests exercise. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 34 | +import type { PluginContext } from '@objectstack/core'; |
| 35 | +import { AUDIENCE_POSTURES } from '@objectstack/spec/system'; |
| 36 | +import { AuthPlugin } from './auth-plugin.js'; |
| 37 | +import { AuthManager } from './auth-manager.js'; |
| 38 | + |
| 39 | +const SECRET = 'test-secret-at-least-32-chars-long'; |
| 40 | + |
| 41 | +type SettingEntry = { value: unknown; source: string }; |
| 42 | + |
| 43 | +/** Minimal engine: enough for boot-time hooks (backfill sees zero users). */ |
| 44 | +function makeEngine() { |
| 45 | + return { |
| 46 | + insert: vi.fn(async (_object: string, row: any) => row), |
| 47 | + find: vi.fn(async () => []), |
| 48 | + findOne: vi.fn(async () => null), |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +describe('auth.audience_* — the settings switch surface (#11768)', () => { |
| 53 | + let mockContext: PluginContext; |
| 54 | + let hookHandlers: Map<string, Array<() => Promise<void>>>; |
| 55 | + |
| 56 | + const settingsStore: { values: Record<string, SettingEntry> } = { values: {} }; |
| 57 | + let subscribers: Array<() => void>; |
| 58 | + |
| 59 | + const makeSettings = () => ({ |
| 60 | + getNamespace: vi.fn(async (namespace: string) => |
| 61 | + namespace === 'auth' ? { values: settingsStore.values } : { values: {} }, |
| 62 | + ), |
| 63 | + subscribe: vi.fn((namespace: string, cb: () => void) => { |
| 64 | + if (namespace === 'auth') subscribers.push(cb); |
| 65 | + }), |
| 66 | + }); |
| 67 | + |
| 68 | + beforeEach(() => { |
| 69 | + settingsStore.values = {}; |
| 70 | + subscribers = []; |
| 71 | + hookHandlers = new Map(); |
| 72 | + mockContext = { |
| 73 | + registerService: vi.fn(), |
| 74 | + getService: vi.fn((name: string) => { |
| 75 | + if (name === 'manifest') return { register: vi.fn() }; |
| 76 | + if (name === 'settings') return makeSettings(); |
| 77 | + return undefined; |
| 78 | + }), |
| 79 | + getServices: vi.fn(() => new Map()), |
| 80 | + hook: vi.fn((name: string, handler: () => Promise<void>) => { |
| 81 | + if (!hookHandlers.has(name)) hookHandlers.set(name, []); |
| 82 | + hookHandlers.get(name)!.push(handler); |
| 83 | + }), |
| 84 | + trigger: vi.fn(), |
| 85 | + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, |
| 86 | + getKernel: vi.fn(), |
| 87 | + } as unknown as PluginContext; |
| 88 | + }); |
| 89 | + |
| 90 | + const fire = async (name: string) => { |
| 91 | + for (const h of hookHandlers.get(name) ?? []) await h(); |
| 92 | + }; |
| 93 | + |
| 94 | + /** Boot exactly as a host does: init → start → kernel:ready (settings bind there). */ |
| 95 | + const boot = async (opts: { |
| 96 | + settings?: Record<string, SettingEntry>; |
| 97 | + pluginOptions?: Record<string, unknown>; |
| 98 | + } = {}) => { |
| 99 | + settingsStore.values = opts.settings ?? {}; |
| 100 | + const engine = makeEngine(); |
| 101 | + (mockContext.getService as any).mockImplementation((name: string) => { |
| 102 | + if (name === 'manifest') return { register: vi.fn() }; |
| 103 | + if (name === 'settings') return makeSettings(); |
| 104 | + if (name === 'data' || name === 'objectql') return engine; |
| 105 | + return undefined; |
| 106 | + }); |
| 107 | + |
| 108 | + const plugin = new AuthPlugin({ |
| 109 | + secret: SECRET, |
| 110 | + baseUrl: 'http://localhost:3000', |
| 111 | + ...(opts.pluginOptions ?? {}), |
| 112 | + }); |
| 113 | + await plugin.init(mockContext); |
| 114 | + const manager = (mockContext.registerService as any).mock.calls.find( |
| 115 | + ([name]: [string]) => name === 'auth', |
| 116 | + )?.[1] as AuthManager; |
| 117 | + await plugin.start(mockContext); |
| 118 | + await fire('kernel:ready'); |
| 119 | + return { plugin, manager, engine }; |
| 120 | + }; |
| 121 | + |
| 122 | + const errorLines = () => |
| 123 | + (mockContext.logger.error as any).mock.calls.map((c: any[]) => String(c[0])); |
| 124 | + |
| 125 | + // ── 1. The declaration reaches the runtime, whole, through ONE patch ───── |
| 126 | + |
| 127 | + it('an explicit email_domain declaration patches the live audience in one stroke', async () => { |
| 128 | + const { manager } = await boot({ |
| 129 | + settings: { |
| 130 | + audience_posture: { value: 'email_domain', source: 'global' }, |
| 131 | + audience_allowed_email_domains: { value: 'acme.com\n Beta.Org , partner.example', source: 'global' }, |
| 132 | + audience_self_registration_permission_set: { value: ' portal_user ', source: 'global' }, |
| 133 | + }, |
| 134 | + }); |
| 135 | + const audience = manager.getAudience(); |
| 136 | + expect(audience.posture).toBe('email_domain'); |
| 137 | + // Newline- AND comma-separated, trimmed; case preserved (matching is |
| 138 | + // case-insensitive at admission, declaration keeps the typed form). |
| 139 | + expect(audience.allowedEmailDomains).toEqual(['acme.com', 'Beta.Org', 'partner.example']); |
| 140 | + expect(audience.selfRegistrationPermissionSet).toBe('portal_user'); |
| 141 | + }); |
| 142 | + |
| 143 | + it('re-applies on a settings change without a restart', async () => { |
| 144 | + const { manager } = await boot(); |
| 145 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 146 | + |
| 147 | + settingsStore.values = { |
| 148 | + audience_posture: { value: 'email_domain', source: 'global' }, |
| 149 | + audience_allowed_email_domains: { value: 'acme.com', source: 'global' }, |
| 150 | + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, |
| 151 | + }; |
| 152 | + expect(subscribers.length).toBeGreaterThan(0); |
| 153 | + for (const cb of subscribers) cb(); |
| 154 | + await vi.waitFor(() => expect(manager.getAudience().posture).toBe('email_domain')); |
| 155 | + }); |
| 156 | + |
| 157 | + // ── 2. Explicit-only (#5152): a UI default must not mask deployment config ─ |
| 158 | + |
| 159 | + it('a manifest default does not mask a deployment that declared its audience in code', async () => { |
| 160 | + const { manager } = await boot({ |
| 161 | + pluginOptions: { |
| 162 | + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, |
| 163 | + }, |
| 164 | + settings: { audience_posture: { value: 'invite_only', source: 'default' } }, |
| 165 | + }); |
| 166 | + expect(manager.getAudience().posture).toBe('open'); |
| 167 | + }); |
| 168 | + |
| 169 | + // ── 3. Off-vocabulary: refused loudly, never coerced ───────────────────── |
| 170 | + |
| 171 | + it('an off-vocabulary posture is refused loudly, never coerced (#5152)', async () => { |
| 172 | + // `invite-only` is the MEMBERSHIP policy spelling — the most plausible |
| 173 | + // operator typo for this select's `invite_only`. Coercing it (to either |
| 174 | + // end of the vocabulary) would silently pick a posture the operator did |
| 175 | + // not declare; refusing keeps the standing config ruling and says so. |
| 176 | + const { manager } = await boot({ |
| 177 | + pluginOptions: { |
| 178 | + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, |
| 179 | + }, |
| 180 | + settings: { audience_posture: { value: 'invite-only', source: 'env' } }, |
| 181 | + }); |
| 182 | + expect(manager.getAudience().posture).toBe('open'); // standing, NOT a coerced guess |
| 183 | + const logged = errorLines(); |
| 184 | + expect(logged.some((m: string) => m.includes("'invite-only'"))).toBe(true); |
| 185 | + expect(logged.some((m: string) => m.includes(AUDIENCE_POSTURES.join(', ')))).toBe(true); |
| 186 | + }); |
| 187 | + |
| 188 | + // ── 4. The ruled invariants hold THROUGH the settings channel ──────────── |
| 189 | + // `applyConfigPatch` validates the merged result and throws; the binding |
| 190 | + // catches and reports, and the standing config keeps ruling (fail closed). |
| 191 | + |
| 192 | + it('an EMPTY domain list under email_domain is refused through the settings channel', async () => { |
| 193 | + const { manager } = await boot({ |
| 194 | + settings: { |
| 195 | + audience_posture: { value: 'email_domain', source: 'global' }, |
| 196 | + // Explicit but blank (whitespace + separators only) — parses to []. |
| 197 | + audience_allowed_email_domains: { value: ' \n , ', source: 'global' }, |
| 198 | + audience_self_registration_permission_set: { value: 'portal_user', source: 'global' }, |
| 199 | + }, |
| 200 | + }); |
| 201 | + expect(manager.getAudience().posture).toBe('invite_only'); // standing default keeps ruling |
| 202 | + const logged = errorLines(); |
| 203 | + expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); |
| 204 | + expect(logged.some((m: string) => m.includes('allowedEmailDomains'))).toBe(true); |
| 205 | + }); |
| 206 | + |
| 207 | + it('a MISSING self-registration permission set is refused through the settings channel', async () => { |
| 208 | + const { manager } = await boot({ |
| 209 | + settings: { audience_posture: { value: 'open', source: 'global' } }, |
| 210 | + }); |
| 211 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 212 | + const logged = errorLines(); |
| 213 | + expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); |
| 214 | + expect(logged.some((m: string) => m.includes('selfRegistrationPermissionSet'))).toBe(true); |
| 215 | + }); |
| 216 | + |
| 217 | + it('admin_full_access as the self-registration permission set is refused', async () => { |
| 218 | + const { manager } = await boot({ |
| 219 | + settings: { |
| 220 | + audience_posture: { value: 'open', source: 'global' }, |
| 221 | + audience_self_registration_permission_set: { value: 'admin_full_access', source: 'global' }, |
| 222 | + }, |
| 223 | + }); |
| 224 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 225 | + expect(errorLines().some((m: string) => m.includes('admin_full_access'))).toBe(true); |
| 226 | + }); |
| 227 | + |
| 228 | + it('a self-registration posture with verification explicitly OFF is refused', async () => { |
| 229 | + // The audience patch is applied AFTER the main patch, so the merged-result |
| 230 | + // validation judges it against the `require_email_verification: false` |
| 231 | + // this same pass just applied — the #11739 "verification forced when |
| 232 | + // posture permits self-registration" invariant, held through the new door. |
| 233 | + const { manager } = await boot({ |
| 234 | + settings: { |
| 235 | + require_email_verification: { value: false, source: 'global' }, |
| 236 | + audience_posture: { value: 'open', source: 'global' }, |
| 237 | + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, |
| 238 | + }, |
| 239 | + }); |
| 240 | + // The main patch itself applied… |
| 241 | + expect((manager as any).config.emailAndPassword?.requireEmailVerification).toBe(false); |
| 242 | + // …and the audience that contradicts it was refused: standing rules. |
| 243 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 244 | + expect(errorLines().some((m: string) => m.includes('requireEmailVerification'))).toBe(true); |
| 245 | + }); |
| 246 | + |
| 247 | + // ── 5. Composition: posture anchors the declaration ────────────────────── |
| 248 | + |
| 249 | + it('switching BACK to invite_only never fails on leftover sibling fields', async () => { |
| 250 | + // The console keeps stored values for fields the posture select now hides. |
| 251 | + // Sending them would make CLOSING the wall refusable (inert-declaration |
| 252 | + // refusal) while the previous, more open posture keeps ruling — the one |
| 253 | + // direction that must not fail. The binding composes the declaration from |
| 254 | + // the keys the selected posture READS: `{ posture: 'invite_only' }` alone. |
| 255 | + const { manager } = await boot({ |
| 256 | + pluginOptions: { |
| 257 | + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, |
| 258 | + }, |
| 259 | + settings: { |
| 260 | + audience_posture: { value: 'invite_only', source: 'global' }, |
| 261 | + audience_allowed_email_domains: { value: 'acme.com', source: 'global' }, |
| 262 | + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, |
| 263 | + }, |
| 264 | + }); |
| 265 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 266 | + expect(errorLines()).toEqual([]); |
| 267 | + }); |
| 268 | + |
| 269 | + it('a domain list without an explicit posture is refused, not guessed', async () => { |
| 270 | + const { manager } = await boot({ |
| 271 | + settings: { audience_allowed_email_domains: { value: 'acme.com', source: 'global' } }, |
| 272 | + }); |
| 273 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 274 | + expect((manager as any).config.audience).toBeUndefined(); // no patch went out |
| 275 | + expect(errorLines().some((m: string) => m.includes('without a posture'))).toBe(true); |
| 276 | + }); |
| 277 | + |
| 278 | + // ── 6. Blast radius: a refused audience never blocks sibling settings ──── |
| 279 | + |
| 280 | + it('a refused audience declaration does not block sibling auth settings', async () => { |
| 281 | + const { manager } = await boot({ |
| 282 | + settings: { |
| 283 | + session_expiry_days: { value: 3, source: 'global' }, |
| 284 | + audience_posture: { value: 'open', source: 'global' }, // no permission set ⇒ refused |
| 285 | + }, |
| 286 | + }); |
| 287 | + expect((manager as any).config.session?.expiresIn).toBe(3 * 86_400); |
| 288 | + expect(manager.getAudience().posture).toBe('invite_only'); |
| 289 | + expect(errorLines().some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); |
| 290 | + }); |
| 291 | + |
| 292 | + // ── 7. Dangling names are an ADMISSION-time refusal, same accessor ─────── |
| 293 | + |
| 294 | + it('a well-formed but dangling permission-set name flows to the ONE accessor the admission gate reads', async () => { |
| 295 | + // The patch validator cannot resolve names (no data access); the dangling |
| 296 | + // declaration is refused at ADMISSION time with 403 AUTH_CONFIG_ERROR — |
| 297 | + // pinned in audience-posture.test.ts ("a DANGLING declared permission set |
| 298 | + // refuses admission…"). That gate reads `getAudience()`, so this pin — |
| 299 | + // the settings channel landing on the same accessor — is what connects |
| 300 | + // the two: no captured copy, no second read site. |
| 301 | + const { manager } = await boot({ |
| 302 | + settings: { |
| 303 | + audience_posture: { value: 'open', source: 'global' }, |
| 304 | + audience_self_registration_permission_set: { value: 'ghost', source: 'global' }, |
| 305 | + }, |
| 306 | + }); |
| 307 | + const audience = manager.getAudience(); |
| 308 | + expect(audience.posture).toBe('open'); |
| 309 | + expect(audience.selfRegistrationPermissionSet).toBe('ghost'); |
| 310 | + }); |
| 311 | +}); |
0 commit comments