|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#28709] `client.auth.mePermissions()`, the typed read of |
| 5 | + * `GET /auth/me/permissions`, whose body `MePermissionsResponseSchema` |
| 6 | + * (`@objectstack/spec/api`) declares strictly. |
| 7 | + * |
| 8 | + * Driven over the REAL handler: plugin-hono-server's |
| 9 | + * `registerCurrentUserEndpoints` mounted on the adapter's raw Hono app (the |
| 10 | + * one `HonoServerPlugin` mounts it on), with the client's |
| 11 | + * transport handing every request to that app. So the URL the method builds is |
| 12 | + * proven by the route answering it, and the body it resolves is the one the |
| 13 | + * handler actually writes, not a fixture typed to look like it. Same mount |
| 14 | + * shape as plugin-hono-server's own has-any-queue suite: the real registrar |
| 15 | + * and a locator that THROWS for an unregistered slot, as the kernel's does. |
| 16 | + */ |
| 17 | + |
| 18 | +import { describe, it, expect } from 'vitest'; |
| 19 | +import { MePermissionsResponseSchema } from '@objectstack/spec/api'; |
| 20 | +import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; |
| 21 | +import { HonoHttpServer, registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server'; |
| 22 | +import { ObjectStackClient } from './index'; |
| 23 | +import { ROUTE_LEDGER } from '../../runtime/src/route-ledger'; |
| 24 | + |
| 25 | +const BASE = 'http://localhost:3000'; |
| 26 | +const USER = 'usr_member'; |
| 27 | +const ACTIVE_ORG = 'org_active'; |
| 28 | + |
| 29 | +type Row = Record<string, any>; |
| 30 | + |
| 31 | +/** The session's organization membership: what the resolver reads to stamp `tenantId`. */ |
| 32 | +const TABLES: Record<string, Row[]> = { |
| 33 | + sys_user: [{ id: USER, email: 'member@example.com' }], |
| 34 | + sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], |
| 35 | +}; |
| 36 | + |
| 37 | +/** Scalar equality only; any combinator is refused rather than misread. */ |
| 38 | +function matches(row: Row, where: Row | undefined): boolean { |
| 39 | + return Object.entries(where ?? {}).every(([key, cond]) => { |
| 40 | + if (key.startsWith('$') || (cond !== null && typeof cond === 'object')) { |
| 41 | + throw new Error(`fake driver: unsupported predicate on ${key}`); |
| 42 | + } |
| 43 | + return (row[key] ?? null) === (cond ?? null); |
| 44 | + }); |
| 45 | +} |
| 46 | + |
| 47 | +const ql = { |
| 48 | + find: async (object: string, opts: any) => { |
| 49 | + const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where)); |
| 50 | + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; |
| 51 | + }, |
| 52 | + registry: { getAllApps: () => [], getAllObjects: () => [] }, |
| 53 | + getSchema: () => undefined, |
| 54 | +}; |
| 55 | + |
| 56 | +/** A client whose every request is answered by the real current-user handlers. */ |
| 57 | +function mount({ signedIn }: { signedIn: boolean }) { |
| 58 | + const services: Record<string, unknown> = { |
| 59 | + auth: { |
| 60 | + api: { |
| 61 | + getSession: async () => (signedIn |
| 62 | + ? { user: { id: USER, email: 'member@example.com' }, session: { activeOrganizationId: ACTIVE_ORG } } |
| 63 | + : null), |
| 64 | + }, |
| 65 | + }, |
| 66 | + objectql: ql, |
| 67 | + metadata: { list: async () => [] as unknown[] }, |
| 68 | + security: { |
| 69 | + resolvePermissionSetsForContext: async () => [ |
| 70 | + { name: 'member_default', systemPermissions: [], objects: {}, fields: {} }, |
| 71 | + ], |
| 72 | + }, |
| 73 | + [OWNERSHIP_QUEUE_SERVICE]: { hasAnyQueue: async () => true }, |
| 74 | + }; |
| 75 | + // The adapter's own Hono app, never listening: the app the plugin mounts on. |
| 76 | + const app = new HonoHttpServer(0).getRawApp(); |
| 77 | + registerCurrentUserEndpoints({ |
| 78 | + rawApp: app, |
| 79 | + ctx: { |
| 80 | + logger: { debug() {}, warn() {} }, |
| 81 | + getService: <T,>(name: string): T => { |
| 82 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 83 | + return services[name] as T; |
| 84 | + }, |
| 85 | + }, |
| 86 | + }); |
| 87 | + const requests: Array<{ method: string; url: string }> = []; |
| 88 | + const client = new ObjectStackClient({ |
| 89 | + baseUrl: BASE, |
| 90 | + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { |
| 91 | + requests.push({ method: (init?.method ?? 'GET').toUpperCase(), url: String(input) }); |
| 92 | + return app.request(String(input), init); |
| 93 | + }, |
| 94 | + }); |
| 95 | + return { client, requests }; |
| 96 | +} |
| 97 | + |
| 98 | +describe('[#28709] client.auth.mePermissions over the real /auth/me/permissions handler', () => { |
| 99 | + it('GETs /api/v1/auth/me/permissions and resolves the authenticated body the handler writes', async () => { |
| 100 | + const { client, requests } = mount({ signedIn: true }); |
| 101 | + const answer = await client.auth.mePermissions(); |
| 102 | + |
| 103 | + expect(requests).toEqual([{ method: 'GET', url: `${BASE}/api/v1/auth/me/permissions` }]); |
| 104 | + // The declared contract holds the resolved value: a key the handler |
| 105 | + // sends that the spec does not declare reds here. |
| 106 | + const parsed = MePermissionsResponseSchema.safeParse(answer); |
| 107 | + expect(parsed.success ? [] : parsed.error.issues).toEqual([]); |
| 108 | + // The type narrows on the discriminator, onto the caller's own facts. |
| 109 | + if (!answer.authenticated) throw new Error('expected the authenticated branch'); |
| 110 | + expect(answer.userId).toBe(USER); |
| 111 | + expect(answer.tenantId).toBe(ACTIVE_ORG); |
| 112 | + expect(answer.hasAnyQueue).toBe(true); |
| 113 | + expect(answer.permissionSets).toContain('member_default'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('an anonymous caller resolves { authenticated: false }, not a throw', async () => { |
| 117 | + const { client } = mount({ signedIn: false }); |
| 118 | + const answer = await client.auth.mePermissions(); |
| 119 | + expect(answer).toEqual({ authenticated: false }); |
| 120 | + expect(MePermissionsResponseSchema.safeParse(answer).success).toBe(true); |
| 121 | + }); |
| 122 | + |
| 123 | + it('the route ledger names this method and the schema these cases parse with', () => { |
| 124 | + const row = ROUTE_LEDGER.find((r) => r.route === 'GET /auth/me/permissions'); |
| 125 | + expect(row).toMatchObject({ |
| 126 | + domain: '/auth', |
| 127 | + disposition: 'sdk', |
| 128 | + client: 'auth.mePermissions', |
| 129 | + responseSchema: 'MePermissionsResponseSchema', |
| 130 | + }); |
| 131 | + }); |
| 132 | +}); |
0 commit comments