|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #25079 — `reconcileFederatedBindings`' report for a federated object whose |
| 5 | + * datasource the connect POLICY denied. |
| 6 | + * |
| 7 | + * `os migrate apply` on the stock showcase denies its `schemaMode: 'external'` |
| 8 | + * datasource on purpose (the CLI's managed-only connect policy) and then |
| 9 | + * printed, at ERROR, that the federated objects "keep their REST routes and |
| 10 | + * keep rendering in the UI, while every read … resolves to a table named after |
| 11 | + * the OBJECT". For a denied datasource that consequence is false on every |
| 12 | + * composition: the datasource layer records the verdict as kind `blocked` |
| 13 | + * (`engine.markDatasourceUnavailable`), and every read against such an object |
| 14 | + * refuses with `DatasourceUnavailableError`. So the report is judged on that |
| 15 | + * recorded verdict alone, never on what the composition serves: |
| 16 | + * |
| 17 | + * - `blocked` → ONE `info` line, outside the ERROR; |
| 18 | + * - undeclared / `failed` / a driver without `registerExternalObject` / |
| 19 | + * a registration that threw → the ERROR, unchanged, as before. |
| 20 | + * |
| 21 | + * The composition is not read by the code under test; the two shapes below |
| 22 | + * pin that a migrate-shaped and a serving-shaped context answer alike. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect } from 'vitest'; |
| 26 | +import type { ServiceObject } from '@objectstack/spec/data'; |
| 27 | +import { ObjectQL } from './engine.js'; |
| 28 | +import { ObjectQLPlugin } from './plugin.js'; |
| 29 | + |
| 30 | +interface Recorded { |
| 31 | + level: 'debug' | 'info' | 'warn' | 'error'; |
| 32 | + message: string; |
| 33 | + args: unknown[]; |
| 34 | +} |
| 35 | + |
| 36 | +function recordingLogger() { |
| 37 | + const records: Recorded[] = []; |
| 38 | + const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) => |
| 39 | + void records.push({ level, message: String(message), args }); |
| 40 | + return { |
| 41 | + records, |
| 42 | + logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') }, |
| 43 | + at(level: Recorded['level']) { |
| 44 | + return records.filter((r) => r.level === level); |
| 45 | + }, |
| 46 | + }; |
| 47 | +} |
| 48 | + |
| 49 | +/** A federated object bound to `datasource` by an explicit `object.datasource`. */ |
| 50 | +function federated(name: string, datasource: string): ServiceObject { |
| 51 | + return { |
| 52 | + name, |
| 53 | + label: name, |
| 54 | + datasource, |
| 55 | + fields: { id: { name: 'id', label: 'ID', type: 'text' as const } }, |
| 56 | + external: { remoteName: `remote_${name}` }, |
| 57 | + } as ServiceObject; |
| 58 | +} |
| 59 | + |
| 60 | +/** A driver registered under `name`; `register` decides what `registerExternalObject` does. */ |
| 61 | +function namedDriver(name: string, register?: 'ok' | 'throws') { |
| 62 | + return { |
| 63 | + name, |
| 64 | + supports: {}, |
| 65 | + async syncSchema() { /* not reached for a federated object */ }, |
| 66 | + async find() { return []; }, |
| 67 | + ...(register |
| 68 | + ? { |
| 69 | + async registerExternalObject() { |
| 70 | + if (register === 'throws') throw new Error('remote catalog unreachable'); |
| 71 | + }, |
| 72 | + } |
| 73 | + : {}), |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +/** The two composition shapes: one that serves (an `http.server` is published) and a migrate run's (none is). */ |
| 78 | +const SHAPES = { |
| 79 | + serving: (logger: unknown) => ({ |
| 80 | + logger, |
| 81 | + getService: (n: string) => { |
| 82 | + if (n === 'http.server') return {}; |
| 83 | + throw new Error(`[Kernel] Service '${n}' not found`); |
| 84 | + }, |
| 85 | + }), |
| 86 | + migrate: (logger: unknown) => ({ |
| 87 | + logger, |
| 88 | + getService: (n: string) => { |
| 89 | + throw new Error(`[Kernel] Service '${n}' not found`); |
| 90 | + }, |
| 91 | + }), |
| 92 | +} as const; |
| 93 | + |
| 94 | +type Setup = { |
| 95 | + objects: ServiceObject[]; |
| 96 | + drivers?: unknown[]; |
| 97 | + unavailable?: Array<{ name: string; kind: 'blocked' | 'failed' }>; |
| 98 | +}; |
| 99 | + |
| 100 | +async function reconcile(setup: Setup, shape: keyof typeof SHAPES = 'serving') { |
| 101 | + const rec = recordingLogger(); |
| 102 | + const engine = new ObjectQL({ logger: rec.logger } as any); |
| 103 | + engine.registerDriver(namedDriver('default', 'ok') as any, true); |
| 104 | + for (const d of setup.drivers ?? []) engine.registerDriver(d as any); |
| 105 | + for (const obj of setup.objects) engine.registerObject(obj as any); |
| 106 | + for (const u of setup.unavailable ?? []) engine.markDatasourceUnavailable(u); |
| 107 | + const plugin = new ObjectQLPlugin(); |
| 108 | + (plugin as any).ql = engine; |
| 109 | + await (plugin as any).reconcileFederatedBindings(SHAPES[shape](rec.logger)); |
| 110 | + return rec; |
| 111 | +} |
| 112 | + |
| 113 | +/** The info lines that report a policy denial — identified by the object they name. */ |
| 114 | +const deniedLines = (rec: ReturnType<typeof recordingLogger>, object: string) => |
| 115 | + rec.at('info').filter((r) => r.message.includes(object)); |
| 116 | + |
| 117 | +describe('#25079 a policy-denied (`blocked`) datasource is reported at info, never in the ERROR', () => { |
| 118 | + for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) { |
| 119 | + it(`one info line naming the denial, the datasource and the object; no ERROR (${shape}-shaped composition)`, async () => { |
| 120 | + const rec = await reconcile( |
| 121 | + { |
| 122 | + objects: [federated('fed_customer', 'ext_denied'), federated('fed_order', 'ext_denied')], |
| 123 | + unavailable: [{ name: 'ext_denied', kind: 'blocked' }], |
| 124 | + }, |
| 125 | + shape, |
| 126 | + ); |
| 127 | + |
| 128 | + expect(rec.at('error')).toHaveLength(0); |
| 129 | + const lines = deniedLines(rec, 'fed_customer'); |
| 130 | + expect(lines).toHaveLength(1); |
| 131 | + expect(lines[0].message).toContain('fed_order'); |
| 132 | + expect(lines[0].message).toContain("'ext_denied'"); |
| 133 | + expect(lines[0].message).toContain('denied'); |
| 134 | + expect(lines[0].message).toContain('DatasourceUnavailableError'); |
| 135 | + expect(lines[0].args[0]).toEqual({ denied: 2 }); |
| 136 | + }); |
| 137 | + } |
| 138 | +}); |
| 139 | + |
| 140 | +describe('#25079 controls — every other unbound class keeps the ERROR, on either composition', () => { |
| 141 | + const CONTROLS: Array<{ label: string; object: string; setup: Setup; meta: Record<string, number> }> = [ |
| 142 | + { |
| 143 | + label: 'an undeclared datasource (no recorded verdict)', |
| 144 | + object: 'fed_orphan', |
| 145 | + setup: { objects: [federated('fed_orphan', 'ext_missing')] }, |
| 146 | + meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 }, |
| 147 | + }, |
| 148 | + { |
| 149 | + label: "a FAILED connect (kind 'failed')", |
| 150 | + object: 'fed_down', |
| 151 | + setup: { |
| 152 | + objects: [federated('fed_down', 'ext_down')], |
| 153 | + unavailable: [{ name: 'ext_down', kind: 'failed' }], |
| 154 | + }, |
| 155 | + meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 }, |
| 156 | + }, |
| 157 | + { |
| 158 | + label: 'a driver without external-object registration', |
| 159 | + object: 'fed_plain', |
| 160 | + setup: { objects: [federated('fed_plain', 'ext_plain')], drivers: [namedDriver('ext_plain')] }, |
| 161 | + meta: { bound: 0, unbound: 0, unsupported: 1, failed: 0 }, |
| 162 | + }, |
| 163 | + { |
| 164 | + label: 'a registration that threw', |
| 165 | + object: 'fed_throws', |
| 166 | + setup: { objects: [federated('fed_throws', 'ext_throws')], drivers: [namedDriver('ext_throws', 'throws')] }, |
| 167 | + meta: { bound: 0, unbound: 0, unsupported: 0, failed: 1 }, |
| 168 | + }, |
| 169 | + ]; |
| 170 | + |
| 171 | + for (const c of CONTROLS) { |
| 172 | + for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) { |
| 173 | + it(`${c.label} → the ERROR (${shape}-shaped)`, async () => { |
| 174 | + const rec = await reconcile(c.setup, shape); |
| 175 | + |
| 176 | + const errors = rec.at('error'); |
| 177 | + expect(errors).toHaveLength(1); |
| 178 | + expect(errors[0].message).toContain(c.object); |
| 179 | + expect(errors[0].message).toContain('NOT bound to their remote table'); |
| 180 | + expect(errors[0].args[1]).toEqual(c.meta); |
| 181 | + expect(deniedLines(rec, c.object)).toHaveLength(0); |
| 182 | + }); |
| 183 | + } |
| 184 | + } |
| 185 | +}); |
| 186 | + |
| 187 | +describe('#25079 a mixed boot — info for the denied, the ERROR for the rest', () => { |
| 188 | + it('splits by verdict, and the ERROR counts exclude the denied objects', async () => { |
| 189 | + const rec = await reconcile({ |
| 190 | + objects: [ |
| 191 | + federated('fed_customer', 'ext_denied'), |
| 192 | + federated('fed_orphan', 'ext_missing'), |
| 193 | + federated('fed_down', 'ext_down'), |
| 194 | + ], |
| 195 | + unavailable: [ |
| 196 | + { name: 'ext_denied', kind: 'blocked' }, |
| 197 | + { name: 'ext_down', kind: 'failed' }, |
| 198 | + ], |
| 199 | + }); |
| 200 | + |
| 201 | + const info = deniedLines(rec, 'fed_customer'); |
| 202 | + expect(info).toHaveLength(1); |
| 203 | + expect(info[0].args[0]).toEqual({ denied: 1 }); |
| 204 | + expect(info[0].message).not.toContain('fed_orphan'); |
| 205 | + expect(info[0].message).not.toContain('fed_down'); |
| 206 | + |
| 207 | + const errors = rec.at('error'); |
| 208 | + expect(errors).toHaveLength(1); |
| 209 | + expect(errors[0].message).toMatch(/^2 federated \(external\) object\(s\) are NOT bound/); |
| 210 | + expect(errors[0].message).toContain('fed_orphan'); |
| 211 | + expect(errors[0].message).toContain('fed_down'); |
| 212 | + expect(errors[0].message).not.toContain('fed_customer'); |
| 213 | + expect(errors[0].args[1]).toEqual({ bound: 0, unbound: 2, unsupported: 0, failed: 0 }); |
| 214 | + }); |
| 215 | +}); |
0 commit comments