|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// cloud#927 — WHICH kernel answers the current-user endpoints. |
| 4 | +// |
| 5 | +// These three resolved their answer from the locator captured at REGISTRATION |
| 6 | +// time. On a single-environment host that is the only kernel and therefore |
| 7 | +// right. On a multi-tenant host it is the routing shell: cloud's |
| 8 | +// `ArtifactKernelFactory` mounts `AuthPlugin` per ENVIRONMENT, and its host |
| 9 | +// kernel deliberately has no `auth` at all ("AuthPlugin is intentionally NOT |
| 10 | +// injected on the host"). So `ctx.getService('auth')` threw there, `resolveCtx` |
| 11 | +// fell to its catch, and every authenticated tenant caller got |
| 12 | +// `{authenticated:false}` — which objectui's `MePermissionsProvider` reads as |
| 13 | +// ANONYMOUS and fails OPEN on (`return data.authenticated !== true`). The |
| 14 | +// server still enforces per request, so it was not a bypass; the client's FLS |
| 15 | +// hints were just systematically wrong. |
| 16 | +// |
| 17 | +// The framework already declares the seam for this: `kernel-resolver` |
| 18 | +// (ADR-0006 Phase 5), which the dispatcher has consulted since. These endpoints |
| 19 | +// not consulting it was the defect. |
| 20 | + |
| 21 | +import { describe, it, expect, vi } from 'vitest'; |
| 22 | +import { Hono } from 'hono'; |
| 23 | +import { registerCurrentUserEndpoints } from './current-user-endpoints'; |
| 24 | + |
| 25 | +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; |
| 26 | +const ME_LOCALIZATION = '/api/v1/auth/me/localization'; |
| 27 | +const ME_APPS = '/api/v1/me/apps'; |
| 28 | + |
| 29 | +/** A kernel stub whose `getService` throws for anything not seeded (like the real one). */ |
| 30 | +function kernelWith(services: Record<string, any>) { |
| 31 | + return { |
| 32 | + getService: <T,>(name: string): T => { |
| 33 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 34 | + return services[name] as T; |
| 35 | + }, |
| 36 | + }; |
| 37 | +} |
| 38 | + |
| 39 | +/** An `auth` service that resolves one fixed user. */ |
| 40 | +const authFor = (userId: string, extra: Record<string, unknown> = {}) => ({ |
| 41 | + api: { getSession: vi.fn(async () => ({ user: { id: userId }, session: extra })) }, |
| 42 | +}); |
| 43 | + |
| 44 | +/** |
| 45 | + * Mount on a bare Hono app over `hostServices`. `resolveKernel` is installed as |
| 46 | + * the host's `kernel-resolver` service — after registration, so the tests also |
| 47 | + * pin that the seam is read LAZILY (cloud registers these routes before |
| 48 | + * `kernel.bootstrap()`, i.e. before the plugin that registers the resolver has |
| 49 | + * run its `init()`; capturing the service at registration time would find |
| 50 | + * nothing and silently keep the old behaviour). |
| 51 | + */ |
| 52 | +function mount(hostServices: Record<string, any>, resolveKernel?: (...a: any[]) => any) { |
| 53 | + const app = new Hono(); |
| 54 | + const services: Record<string, any> = { ...hostServices }; |
| 55 | + const kernel = kernelWith(services); |
| 56 | + const ctx = { |
| 57 | + logger: { debug() {}, warn() {} }, |
| 58 | + getService: <T,>(name: string): T => kernel.getService<T>(name), |
| 59 | + getKernel: () => kernel, |
| 60 | + }; |
| 61 | + |
| 62 | + registerCurrentUserEndpoints({ rawApp: app, ctx }); |
| 63 | + |
| 64 | + // Registered AFTER — the lazy read is what makes this visible. |
| 65 | + if (resolveKernel) services['kernel-resolver'] = { resolveKernel }; |
| 66 | + |
| 67 | + return { app, kernel, services }; |
| 68 | +} |
| 69 | + |
| 70 | +const get = (app: any, path: string) => |
| 71 | + app.request(`http://tenant.example.com${path}`, { headers: { host: 'tenant.example.com' } }); |
| 72 | + |
| 73 | +describe('the answer comes from the kernel that OWNS the request (cloud#927)', () => { |
| 74 | + it('a resolved environment kernel answers, not the routing shell', async () => { |
| 75 | + // The shell has no `auth` — exactly cloud's objectos host. |
| 76 | + const envKernel = kernelWith({ auth: authFor('usr_env') }); |
| 77 | + const { app } = mount({}, async () => envKernel); |
| 78 | + |
| 79 | + const res = await get(app, ME_PERMISSIONS); |
| 80 | + |
| 81 | + expect(res.status).toBe(200); |
| 82 | + // Before this, the shell's missing `auth` made this `{authenticated:false}` |
| 83 | + // for every real caller — the fail-open the client then inherited. |
| 84 | + expect(await res.json()).toMatchObject({ authenticated: true, userId: 'usr_env' }); |
| 85 | + }); |
| 86 | + |
| 87 | + it('prefers the environment kernel over a host that DOES have auth', async () => { |
| 88 | + // Pins that resolution wins rather than merely filling a gap: a host with |
| 89 | + // its own identity must not answer for a tenant request. |
| 90 | + const envKernel = kernelWith({ auth: authFor('usr_env') }); |
| 91 | + const { app } = mount({ auth: authFor('usr_host') }, async () => envKernel); |
| 92 | + |
| 93 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_env' }); |
| 94 | + }); |
| 95 | + |
| 96 | + it('routes /auth/me/localization and /me/apps through the same resolution', async () => { |
| 97 | + const envKernel = kernelWith({ |
| 98 | + auth: authFor('usr_env'), |
| 99 | + objectql: { _registry: { getAllApps: () => [{ name: 'env_app' }] } }, |
| 100 | + }); |
| 101 | + const { app } = mount({}, async () => envKernel); |
| 102 | + |
| 103 | + expect(await (await get(app, ME_LOCALIZATION)).json()).toMatchObject({ authenticated: true }); |
| 104 | + expect(await (await get(app, ME_APPS)).json()).toEqual({ apps: [{ name: 'env_app' }] }); |
| 105 | + }); |
| 106 | + |
| 107 | + it('hands the resolver the prefix-stripped routePath the dispatcher would', async () => { |
| 108 | + // cloud's resolver applies its own path policy to `routePath` (control-plane |
| 109 | + // prefixes skip environment resolution), so the shape has to match. |
| 110 | + const seen: any[] = []; |
| 111 | + const envKernel = kernelWith({ auth: authFor('usr_env') }); |
| 112 | + const { app, kernel } = mount({}, async (context: any, defaultKernel: any) => { |
| 113 | + seen.push({ routePath: context.routePath, isDefault: defaultKernel === kernel, headers: context.request?.headers }); |
| 114 | + return envKernel; |
| 115 | + }); |
| 116 | + |
| 117 | + await get(app, ME_PERMISSIONS); |
| 118 | + await get(app, ME_APPS); |
| 119 | + |
| 120 | + expect(seen.map((s) => s.routePath)).toEqual(['/auth/me/permissions', '/me/apps']); |
| 121 | + // The seam's second argument is the host kernel, as `KernelResolver` declares. |
| 122 | + expect(seen.every((s) => s.isDefault)).toBe(true); |
| 123 | + // Cloud's resolver reads `host` / `x-environment-id` off these. |
| 124 | + expect(seen[0].headers).toBeInstanceOf(Headers); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('the default kernel still answers where the seam says it should', () => { |
| 129 | + it('no kernel-resolver at all — single-environment hosts are untouched', async () => { |
| 130 | + const { app } = mount({ auth: authFor('usr_host') }); |
| 131 | + |
| 132 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); |
| 133 | + }); |
| 134 | + |
| 135 | + it('resolver returns undefined — the contract for an unscoped request', async () => { |
| 136 | + const { app } = mount({ auth: authFor('usr_host') }, async () => undefined); |
| 137 | + |
| 138 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); |
| 139 | + }); |
| 140 | + |
| 141 | + it('resolver hands back the default kernel itself', async () => { |
| 142 | + const { app, kernel } = mount({ auth: authFor('usr_host') }, async (_c: any, d: any) => d ?? kernel); |
| 143 | + |
| 144 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); |
| 145 | + }); |
| 146 | + |
| 147 | + it('a locator with no getKernel cannot be asked, so it answers itself', async () => { |
| 148 | + // Documented downgrade, not a neutral choice — a multi-tenant host that |
| 149 | + // omits `getKernel` gets the old (wrong) provenance back. |
| 150 | + const app = new Hono(); |
| 151 | + const kernel = kernelWith({ auth: authFor('usr_host'), 'kernel-resolver': { resolveKernel: vi.fn() } }); |
| 152 | + registerCurrentUserEndpoints({ |
| 153 | + rawApp: app, |
| 154 | + ctx: { logger: { debug() {}, warn() {} }, getService: (n) => kernel.getService(n) }, |
| 155 | + }); |
| 156 | + |
| 157 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); |
| 158 | + }); |
| 159 | +}); |
| 160 | + |
| 161 | +describe('a kernel that cannot be reached is a retryable error, never a fake anonymous', () => { |
| 162 | + it('surfaces a warming 503 with Retry-After', async () => { |
| 163 | + // cloud's `KernelWarmingError` shape: a cold environment kernel past the |
| 164 | + // wait budget. AuthProxyPlugin already answers 503 + Retry-After for it. |
| 165 | + // Only `statusCode` + `retryAfterSeconds` are load-bearing — its `code` is |
| 166 | + // read for the log line alone, so the fixture omits it rather than carry a |
| 167 | + // lowercase literal in a `code:` position (ADR-0112 D1). |
| 168 | + const warming = Object.assign(new Error('kernel warming'), { |
| 169 | + statusCode: 503, retryAfterSeconds: 4, |
| 170 | + }); |
| 171 | + const { app } = mount({}, async () => { throw warming; }); |
| 172 | + |
| 173 | + const res = await get(app, ME_PERMISSIONS); |
| 174 | + |
| 175 | + expect(res.status).toBe(503); |
| 176 | + expect(res.headers.get('Retry-After')).toBe('4'); |
| 177 | + expect(await res.json()).toMatchObject({ error: 'environment_unavailable' }); |
| 178 | + }); |
| 179 | + |
| 180 | + it('a resolver failure with no status is a 503, not a fall-back to the shell', async () => { |
| 181 | + // Falling back would answer `{authenticated:false}` off the shell, which the |
| 182 | + // client reads as anonymous and fails OPEN on — worse than a retryable error. |
| 183 | + const { app } = mount({ auth: authFor('usr_host') }, async () => { throw new Error('registry down'); }); |
| 184 | + |
| 185 | + const res = await get(app, ME_PERMISSIONS); |
| 186 | + |
| 187 | + expect(res.status).toBe(503); |
| 188 | + expect(await res.json()).not.toMatchObject({ authenticated: false }); |
| 189 | + }); |
| 190 | + |
| 191 | + it('applies to all three endpoints', async () => { |
| 192 | + const { app } = mount({}, async () => { throw new Error('registry down'); }); |
| 193 | + |
| 194 | + for (const path of [ME_PERMISSIONS, ME_LOCALIZATION, ME_APPS]) { |
| 195 | + expect((await get(app, path)).status, path).toBe(503); |
| 196 | + } |
| 197 | + }); |
| 198 | + |
| 199 | + it('passes a 4xx from the resolver through unchanged', async () => { |
| 200 | + const denied = Object.assign(new Error('nope'), { statusCode: 403 }); |
| 201 | + const { app } = mount({}, async () => { throw denied; }); |
| 202 | + |
| 203 | + expect((await get(app, ME_PERMISSIONS)).status).toBe(403); |
| 204 | + }); |
| 205 | +}); |
0 commit comments