|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The booted app as the runner reaches it: plain HTTP against `os dev`'s |
| 5 | + * server, through the same public doors a person uses. Every path here was |
| 6 | + * measured against a `create-objectstack` blank project on `os dev --fresh` |
| 7 | + * (the runner's PR records the reading): |
| 8 | + * |
| 9 | + * sign in POST /api/v1/auth/sign-in/email → session cookie |
| 10 | + * a persona's user POST /api/v1/auth/admin/create-user (self sign-up is closed |
| 11 | + * on the default `invite_only` audience) → the new user must change |
| 12 | + * the password before anything else, so: sign in, POST |
| 13 | + * /api/v1/auth/change-password, sign in again |
| 14 | + * its API key POST /api/v1/keys (a SESSION door: a key cannot mint a key) → |
| 15 | + * the raw key, returned once; it acts as its owner |
| 16 | + * its access POST /api/v1/data/sys_user_permission_set { user_id, |
| 17 | + * permission_set, organization_id } — a user with no app permission |
| 18 | + * set reads `PERMISSION_DENIED` on every app object |
| 19 | + * a seed row POST /api/v1/data/:object with the card's own `id` (accepted |
| 20 | + * verbatim, so a lookup value in the seed names the real row) |
| 21 | + * rows GET /api/v1/data/:object?limit=… |
| 22 | + * the audit actor GET /api/v1/data/sys_audit_log?record_id=… (`user_id`) |
| 23 | + * unmanaged install POST /api/v1/marketplace/install-local { packageId, manifest, |
| 24 | + * installMode: 'unmanaged' } (ADR-0131 D6) |
| 25 | + * metadata GET /api/v1/meta/:type |
| 26 | + * |
| 27 | + * The data API's refusal is the flat `{ error, code }` envelope; the auth and |
| 28 | + * install doors answer `{ success: false, error: { code, message } }`. Both |
| 29 | + * are read by {@link codeOf}. |
| 30 | + */ |
| 31 | + |
| 32 | +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/meta-spelling'; |
| 33 | + |
| 34 | +export interface HttpAnswer { |
| 35 | + ok: boolean; |
| 36 | + status: number; |
| 37 | + body: unknown; |
| 38 | + code?: string; |
| 39 | + message?: string; |
| 40 | +} |
| 41 | + |
| 42 | +/** The refusal code of either envelope this app answers with. */ |
| 43 | +export function codeOf(body: unknown): { code?: string; message?: string } { |
| 44 | + if (typeof body !== 'object' || body === null) return {}; |
| 45 | + const b = body as Record<string, unknown>; |
| 46 | + if (typeof b.code === 'string') return { code: b.code, ...(typeof b.error === 'string' ? { message: b.error } : {}) }; |
| 47 | + const e = b.error; |
| 48 | + if (typeof e === 'object' && e !== null) { |
| 49 | + const ee = e as Record<string, unknown>; |
| 50 | + return { |
| 51 | + ...(typeof ee.code === 'string' ? { code: ee.code } : {}), |
| 52 | + ...(typeof ee.message === 'string' ? { message: ee.message } : {}), |
| 53 | + }; |
| 54 | + } |
| 55 | + return {}; |
| 56 | +} |
| 57 | + |
| 58 | +export interface PersonaAccount { |
| 59 | + persona: string; |
| 60 | + userId: string; |
| 61 | + email: string; |
| 62 | + apiKey: string; |
| 63 | + /** The permission set the runner assigned, or why it could not. */ |
| 64 | + permissionSet: { name: string; assigned: boolean; code?: string; message?: string }; |
| 65 | +} |
| 66 | + |
| 67 | +/** What the orchestrator needs from a booted app — the seam the tests replace. */ |
| 68 | +export interface AppHarness { |
| 69 | + readonly baseUrl: string; |
| 70 | + readonly mcpUrl: string; |
| 71 | + signInAdmin(): Promise<void>; |
| 72 | + adminUserId(): string; |
| 73 | + organizationId(): string; |
| 74 | + /** One user, its own permission set (named after the persona) and one API key. */ |
| 75 | + createPersona(persona: string): Promise<PersonaAccount>; |
| 76 | + /** An API key for the admin, for the questions no persona is named on. */ |
| 77 | + adminApiKey(): Promise<string>; |
| 78 | + insertRow(object: string, row: Record<string, unknown>): Promise<HttpAnswer>; |
| 79 | + listRows(object: string): Promise<HttpAnswer & { rows: Array<Record<string, unknown>> }>; |
| 80 | + /** The `user_id` the newest audit row for this record names; `null` when none names one. */ |
| 81 | + auditActorUserId(object: string, recordId: string): Promise<string | null>; |
| 82 | + installUnmanaged(manifest: Record<string, unknown>): Promise<HttpAnswer>; |
| 83 | + /** The environment's metadata of every collection the template carries, as an artifact. */ |
| 84 | + metadataAsArtifact(collections: readonly string[]): Promise<Record<string, unknown[]>>; |
| 85 | +} |
| 86 | + |
| 87 | +export interface HttpAppOptions { |
| 88 | + baseUrl: string; |
| 89 | + adminEmail: string; |
| 90 | + adminPassword: string; |
| 91 | + /** Password given to every persona user (dev only; the server is the run's own throwaway). */ |
| 92 | + personaPassword?: string; |
| 93 | +} |
| 94 | + |
| 95 | +class Session { |
| 96 | + private cookies = new Map<string, string>(); |
| 97 | + |
| 98 | + absorb(res: Response): void { |
| 99 | + for (const c of res.headers.getSetCookie()) { |
| 100 | + const [pair] = c.split(';'); |
| 101 | + const i = pair!.indexOf('='); |
| 102 | + if (i > 0) this.cookies.set(pair!.slice(0, i).trim(), pair!.slice(i + 1).trim()); |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + header(): string { |
| 107 | + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +export class HttpApp implements AppHarness { |
| 112 | + readonly baseUrl: string; |
| 113 | + readonly mcpUrl: string; |
| 114 | + private readonly admin = new Session(); |
| 115 | + private adminId = ''; |
| 116 | + private orgId = ''; |
| 117 | + private readonly opts: HttpAppOptions; |
| 118 | + |
| 119 | + constructor(opts: HttpAppOptions) { |
| 120 | + this.opts = opts; |
| 121 | + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); |
| 122 | + this.mcpUrl = `${this.baseUrl}/api/v1/mcp`; |
| 123 | + } |
| 124 | + |
| 125 | + private async call( |
| 126 | + method: string, |
| 127 | + path: string, |
| 128 | + body?: unknown, |
| 129 | + auth?: { session?: Session; apiKey?: string }, |
| 130 | + ): Promise<HttpAnswer> { |
| 131 | + const headers: Record<string, string> = { accept: 'application/json' }; |
| 132 | + if (body !== undefined) headers['content-type'] = 'application/json'; |
| 133 | + if (auth?.session) headers.cookie = auth.session.header(); |
| 134 | + if (auth?.apiKey) headers['x-api-key'] = auth.apiKey; |
| 135 | + const res = await fetch(`${this.baseUrl}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }); |
| 136 | + auth?.session?.absorb(res); |
| 137 | + const text = await res.text(); |
| 138 | + let parsed: unknown = text; |
| 139 | + try { |
| 140 | + parsed = text ? JSON.parse(text) : null; |
| 141 | + } catch { |
| 142 | + /* keep the text */ |
| 143 | + } |
| 144 | + const ok = res.ok && !(typeof parsed === 'object' && parsed !== null && (parsed as { success?: unknown }).success === false); |
| 145 | + return { ok, status: res.status, body: parsed, ...(ok ? {} : codeOf(parsed)) }; |
| 146 | + } |
| 147 | + |
| 148 | + private async signIn(session: Session, email: string, password: string): Promise<HttpAnswer> { |
| 149 | + return this.call('POST', '/api/v1/auth/sign-in/email', { email, password }, { session }); |
| 150 | + } |
| 151 | + |
| 152 | + async signInAdmin(): Promise<void> { |
| 153 | + const r = await this.signIn(this.admin, this.opts.adminEmail, this.opts.adminPassword); |
| 154 | + if (!r.ok) throw new Error(`admin sign-in refused: ${r.status} ${r.code ?? ''} ${r.message ?? ''}`.trim()); |
| 155 | + this.adminId = String((r.body as { user?: { id?: unknown } }).user?.id ?? ''); |
| 156 | + const s = await this.call('GET', '/api/v1/auth/get-session', undefined, { session: this.admin }); |
| 157 | + const session = (s.body as { session?: { activeOrganizationId?: unknown } } | null)?.session; |
| 158 | + this.orgId = typeof session?.activeOrganizationId === 'string' ? session.activeOrganizationId : ''; |
| 159 | + } |
| 160 | + |
| 161 | + adminUserId(): string { |
| 162 | + return this.adminId; |
| 163 | + } |
| 164 | + |
| 165 | + organizationId(): string { |
| 166 | + return this.orgId; |
| 167 | + } |
| 168 | + |
| 169 | + async createPersona(persona: string): Promise<PersonaAccount> { |
| 170 | + const email = `${persona}@scenario.test`; |
| 171 | + const first = `${this.opts.personaPassword ?? 'Scenario-Run-1'}a`; |
| 172 | + const password = this.opts.personaPassword ?? 'Scenario-Run-1'; |
| 173 | + const created = await this.call('POST', '/api/v1/auth/admin/create-user', { email, password: first, name: persona, role: 'user' }, { session: this.admin }); |
| 174 | + if (!created.ok) throw new Error(`create-user ${persona}: ${created.status} ${created.code ?? ''} ${created.message ?? ''}`.trim()); |
| 175 | + const data = (created.body as { data?: { user?: { id?: string }; organizationId?: string } }).data; |
| 176 | + const userId = String(data?.user?.id ?? ''); |
| 177 | + if (!this.orgId && data?.organizationId) this.orgId = data.organizationId; |
| 178 | + |
| 179 | + const s = new Session(); |
| 180 | + await this.signIn(s, email, first); |
| 181 | + const changed = await this.call('POST', '/api/v1/auth/change-password', { currentPassword: first, newPassword: password, revokeOtherSessions: false }, { session: s }); |
| 182 | + if (!changed.ok) throw new Error(`change-password ${persona}: ${changed.status} ${changed.code ?? ''}`.trim()); |
| 183 | + const again = await this.signIn(s, email, password); |
| 184 | + if (!again.ok) throw new Error(`sign-in ${persona}: ${again.status} ${again.code ?? ''}`.trim()); |
| 185 | + |
| 186 | + const grant = await this.call( |
| 187 | + 'POST', |
| 188 | + '/api/v1/data/sys_user_permission_set', |
| 189 | + { user_id: userId, permission_set: persona, organization_id: this.orgId }, |
| 190 | + { session: this.admin }, |
| 191 | + ); |
| 192 | + const key = await this.call('POST', '/api/v1/keys', { name: `scenario-${persona}` }, { session: s }); |
| 193 | + const raw = (key.body as { data?: { key?: unknown } } | null)?.data?.key; |
| 194 | + if (!key.ok || typeof raw !== 'string') throw new Error(`mint key ${persona}: ${key.status} ${key.code ?? ''}`.trim()); |
| 195 | + return { |
| 196 | + persona, |
| 197 | + userId, |
| 198 | + email, |
| 199 | + apiKey: raw, |
| 200 | + permissionSet: { |
| 201 | + name: persona, |
| 202 | + assigned: grant.ok, |
| 203 | + ...(grant.code ? { code: grant.code } : {}), |
| 204 | + ...(grant.message ? { message: grant.message.slice(0, 300) } : {}), |
| 205 | + }, |
| 206 | + }; |
| 207 | + } |
| 208 | + |
| 209 | + async adminApiKey(): Promise<string> { |
| 210 | + const key = await this.call('POST', '/api/v1/keys', { name: 'scenario-admin' }, { session: this.admin }); |
| 211 | + const raw = (key.body as { data?: { key?: unknown } } | null)?.data?.key; |
| 212 | + if (!key.ok || typeof raw !== 'string') throw new Error(`mint admin key: ${key.status} ${key.code ?? ''}`.trim()); |
| 213 | + return raw; |
| 214 | + } |
| 215 | + |
| 216 | + insertRow(object: string, row: Record<string, unknown>): Promise<HttpAnswer> { |
| 217 | + return this.call('POST', `/api/v1/data/${encodeURIComponent(object)}`, row, { session: this.admin }); |
| 218 | + } |
| 219 | + |
| 220 | + async listRows(object: string): Promise<HttpAnswer & { rows: Array<Record<string, unknown>> }> { |
| 221 | + const r = await this.call('GET', `/api/v1/data/${encodeURIComponent(object)}?limit=1000`, undefined, { session: this.admin }); |
| 222 | + const records = (r.body as { records?: unknown } | null)?.records; |
| 223 | + return { ...r, rows: Array.isArray(records) ? (records as Array<Record<string, unknown>>) : [] }; |
| 224 | + } |
| 225 | + |
| 226 | + async auditActorUserId(object: string, recordId: string): Promise<string | null> { |
| 227 | + const q = `object_name=${encodeURIComponent(object)}&record_id=${encodeURIComponent(recordId)}&limit=200`; |
| 228 | + const r = await this.call('GET', `/api/v1/data/sys_audit_log?${q}`, undefined, { session: this.admin }); |
| 229 | + const rows = ((r.body as { records?: unknown } | null)?.records ?? []) as Array<Record<string, unknown>>; |
| 230 | + const mine = rows.filter((x) => x.record_id === recordId && x.object_name === object); |
| 231 | + mine.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))); |
| 232 | + const actor = mine[0]?.user_id; |
| 233 | + return typeof actor === 'string' ? actor : null; |
| 234 | + } |
| 235 | + |
| 236 | + installUnmanaged(manifest: Record<string, unknown>): Promise<HttpAnswer> { |
| 237 | + const packageId = typeof manifest.id === 'string' ? manifest.id : 'scenario_template'; |
| 238 | + return this.call('POST', '/api/v1/marketplace/install-local', { packageId, manifest, installMode: 'unmanaged' }, { session: this.admin }); |
| 239 | + } |
| 240 | + |
| 241 | + async metadataAsArtifact(collections: readonly string[]): Promise<Record<string, unknown[]>> { |
| 242 | + const out: Record<string, unknown[]> = {}; |
| 243 | + for (const plural of collections) { |
| 244 | + const type = PLURAL_TO_SINGULAR[plural]; |
| 245 | + if (!type) continue; |
| 246 | + const r = await this.call('GET', `/api/v1/meta/${type}`, undefined, { session: this.admin }); |
| 247 | + const items = (r.body as { items?: unknown } | null)?.items; |
| 248 | + out[plural] = Array.isArray(items) ? items.filter((i) => !String((i as { name?: unknown }).name ?? '').match(/^(sys_|ai_)/)) : []; |
| 249 | + } |
| 250 | + return out; |
| 251 | + } |
| 252 | +} |
0 commit comments