|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #31085 — the user page's "AI Assistants" tab: the place an administrator |
| 5 | + * sees, and revokes, the AI assistants a person authorized. |
| 6 | + * |
| 7 | + * The tab's body is the console widget `mcp:authorized-assistants`, which |
| 8 | + * reads the page's bound `sys_user` record and calls |
| 9 | + * `/api/v1/auth/admin/authorized-clients`, a door that refuses every caller |
| 10 | + * below platform admin. So the tab item carries the ADR-0095 D3 PLATFORM_ADMIN |
| 11 | + * rung, in the CEL envelope, the same item-level mechanism the Security tab |
| 12 | + * uses: a viewer the door refuses is not shown a tab that can only draw a 403. |
| 13 | + * |
| 14 | + * Read from the shipped page metadata, so the pin fails if the tab leaves the |
| 15 | + * strip or moves, if its body changes, and if its gate is dropped, loses its |
| 16 | + * envelope, or starts reading standing from `positions`. The gate is evaluated |
| 17 | + * with `@objectstack/formula`'s `celEngine` under the console's binding — the |
| 18 | + * whole scope as `extra`, one subject under `current_user` / `user` / |
| 19 | + * `ctx.user` / `os.user` — and ⛔ not through `user:`, under which the engine |
| 20 | + * re-derives `isPlatformAdmin` from `positions`. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, expect, it } from 'vitest'; |
| 24 | +import { celEngine } from '@objectstack/formula'; |
| 25 | +import { PageSchema } from '@objectstack/spec/ui'; |
| 26 | +import { SysUserDetailPage } from './sys-user.page.js'; |
| 27 | + |
| 28 | +type Rec = Record<string, unknown>; |
| 29 | + |
| 30 | +/** The standing, spelled as the object's platform-admin actions spell it. */ |
| 31 | +const STANDING = 'current_user.isPlatformAdmin == true'; |
| 32 | + |
| 33 | +/** The strip, in order: the new tab sits beside API Keys. */ |
| 34 | +const STRIP = [ |
| 35 | + 'Details', |
| 36 | + 'Positions', |
| 37 | + 'Permission Sets', |
| 38 | + 'Business Units', |
| 39 | + 'Sessions', |
| 40 | + 'Linked Accounts', |
| 41 | + 'Organizations', |
| 42 | + 'OAuth Apps', |
| 43 | + 'Security', |
| 44 | + 'API Keys', |
| 45 | + 'AI Assistants', |
| 46 | + 'Preferences', |
| 47 | +]; |
| 48 | + |
| 49 | +type Subject = { id: string | null; email: string; isPlatformAdmin: boolean; positions: string[] }; |
| 50 | + |
| 51 | +const PLATFORM_ADMIN: Subject = { id: 'u_pa', email: 'pa@example.com', isPlatformAdmin: true, positions: [] }; |
| 52 | + |
| 53 | +/** Every viewer the door refuses, the grades first, then the position the rung must never be read from. */ |
| 54 | +const REFUSED: ReadonlyArray<[string, Subject]> = [ |
| 55 | + ['org owner', { id: 'u_owner', email: 'owner@example.com', isPlatformAdmin: false, positions: ['org_owner'] }], |
| 56 | + ['org admin', { id: 'u_admin', email: 'admin@example.com', isPlatformAdmin: false, positions: ['org_admin'] }], |
| 57 | + ['plain member', { id: 'u_member', email: 'member@example.com', isPlatformAdmin: false, positions: ['member'] }], |
| 58 | + [ |
| 59 | + "a tenant-written 'platform_admin' position without the rung", |
| 60 | + { id: 'u_pos', email: 'pos@example.com', isPlatformAdmin: false, positions: ['platform_admin', 'org_owner'] }, |
| 61 | + ], |
| 62 | + // The console's signed-out identity (`buildExpressionUser(null)`). |
| 63 | + ['signed out', { id: null, email: '', isPlatformAdmin: false, positions: [] }], |
| 64 | +]; |
| 65 | + |
| 66 | +/** The record the tab is drawn on: someone else's, and the viewer's own. */ |
| 67 | +const records = (subject: Subject): Array<Record<string, unknown>> => [ |
| 68 | + { id: 'u_someone_else', email: 'else@example.com' }, |
| 69 | + { id: subject.id, email: subject.email }, |
| 70 | +]; |
| 71 | + |
| 72 | +function shown(source: string, subject: Subject, record: Record<string, unknown>): boolean { |
| 73 | + const r = celEngine.evaluate( |
| 74 | + { dialect: 'cel', source }, |
| 75 | + { record, extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject } } }, |
| 76 | + ); |
| 77 | + return r.ok && r.value === true; |
| 78 | +} |
| 79 | + |
| 80 | +describe('sys_user_detail — the AI Assistants tab', () => { |
| 81 | + const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec; |
| 82 | + const items = (tabs.properties as Rec).items as Rec[]; |
| 83 | + const tab = items.find((it) => (it.label as Rec).en === 'AI Assistants')!; |
| 84 | + |
| 85 | + it('parses through PageSchema', () => { |
| 86 | + const parsed = PageSchema.safeParse(SysUserDetailPage); |
| 87 | + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); |
| 88 | + }); |
| 89 | + |
| 90 | + it('sits in the strip beside API Keys', () => { |
| 91 | + expect(items.map((it) => (it.label as Rec).en)).toEqual(STRIP); |
| 92 | + }); |
| 93 | + |
| 94 | + it('carries the label in every locale the sibling tabs spell', () => { |
| 95 | + expect(tab.label).toEqual({ |
| 96 | + en: 'AI Assistants', |
| 97 | + 'zh-CN': 'AI 助手', |
| 98 | + 'ja-JP': 'AI アシスタント', |
| 99 | + 'es-ES': 'Asistentes de IA', |
| 100 | + 'zh-Hant': 'AI 助理', |
| 101 | + }); |
| 102 | + for (const sibling of items) { |
| 103 | + expect(Object.keys(sibling.label as Rec).sort(), String((sibling.label as Rec).en)).toEqual( |
| 104 | + Object.keys(tab.label as Rec).sort(), |
| 105 | + ); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + it('mounts the `mcp:authorized-assistants` widget, with no props, as its only child', () => { |
| 110 | + expect(tab.children).toEqual([{ type: 'mcp:authorized-assistants', properties: {} }]); |
| 111 | + }); |
| 112 | + |
| 113 | + it('carries the platform-admin gate on the tab item, in the CEL envelope', () => { |
| 114 | + expect(tab.visibleWhen).toEqual({ dialect: 'cel', source: STANDING }); |
| 115 | + }); |
| 116 | + |
| 117 | + it('is shown to a platform administrator, on any user record', () => { |
| 118 | + const source = (tab.visibleWhen as { source: string }).source; |
| 119 | + for (const record of records(PLATFORM_ADMIN)) { |
| 120 | + expect(shown(source, PLATFORM_ADMIN, record), JSON.stringify(record)).toBe(true); |
| 121 | + } |
| 122 | + }); |
| 123 | + |
| 124 | + it.each(REFUSED)('is not shown to %s, on any user record — own record included', (_name, subject) => { |
| 125 | + const source = (tab.visibleWhen as { source: string }).source; |
| 126 | + for (const record of records(subject)) { |
| 127 | + expect(shown(source, subject, record), JSON.stringify(record)).toBe(false); |
| 128 | + } |
| 129 | + }); |
| 130 | +}); |
0 commit comments