|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Connector credentials at the metadata doors, booted on the showcase with its |
| 4 | +// own connector plugins: |
| 5 | +// |
| 6 | +// - WRITE: the connector save door refuses an inline secret on an instance's |
| 7 | +// `auth` — every static kind (`bearer`, `api-key`, `basic`, `none`) — with |
| 8 | +// the same `422 INVALID_METADATA` envelope the publish door gives an inline |
| 9 | +// `authentication`, located at `auth`, and stores nothing. |
| 10 | +// - CONTROL: an instance that names its secret with `credentialRef` saves, |
| 11 | +// materializes, and dispatches upstream with the reference resolved and its |
| 12 | +// default headers sent — including after a save of its served form. |
| 13 | +// - READ: a stored row holding inline credential positions (seeded through |
| 14 | +// the system context, the shape a row stored before the write refusal can |
| 15 | +// have) is served with those positions withheld, to a member and to an |
| 16 | +// administrator, by name, in the list and in the layered view, and through |
| 17 | +// the metadata protocol's reads; every other field is served as stored, and |
| 18 | +// the stored row itself is never redacted. |
| 19 | +// |
| 20 | +// Every credential below is an obviously-fake placeholder, and every assertion |
| 21 | +// on one is its absence from what a door served. |
| 22 | + |
| 23 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 24 | +import { createServer, type IncomingHttpHeaders, type Server } from 'node:http'; |
| 25 | +import type { AddressInfo } from 'node:net'; |
| 26 | +import { type VerifyStack } from '@objectstack/verify'; |
| 27 | +import { bootShowcase } from './showcase-boot.js'; |
| 28 | + |
| 29 | +const SYSTEM = { isSystem: true, positions: [], permissions: [] }; |
| 30 | + |
| 31 | +const PLACEHOLDERS = { |
| 32 | + inlineToken: 'TEST_PLACEHOLDER_INLINE_TOKEN', |
| 33 | + inlineKey: 'TEST_PLACEHOLDER_INLINE_KEY', |
| 34 | + inlinePassword: 'TEST_PLACEHOLDER_INLINE_PASSWORD', |
| 35 | + headerValue: 'TEST_PLACEHOLDER_HEADER_VALUE', |
| 36 | + defaultHeaderValue: 'TEST_PLACEHOLDER_DEFAULT_HEADER_VALUE', |
| 37 | + runtimeToken: 'TEST_PLACEHOLDER_RUNTIME_TOKEN', |
| 38 | + resolvedRef: 'TEST_PLACEHOLDER_RESOLVED_REFERENCE', |
| 39 | +} as const; |
| 40 | +const ALL_PLACEHOLDERS = Object.values(PLACEHOLDERS); |
| 41 | + |
| 42 | +/** The reference the CONTROL instance names; set in this process's environment before it is saved. */ |
| 43 | +const CONTROL_REF = 'CCH_CONTROL_CREDENTIAL_REF'; |
| 44 | +const CONTROL = 'cch_control'; |
| 45 | +const STORED = 'cch_stored'; |
| 46 | +const REFUSED = 'cch_refused'; |
| 47 | + |
| 48 | +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); |
| 49 | +const bodyOf = (row: any): Record<string, any> => |
| 50 | + typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {}); |
| 51 | +const itemOf = (json: any): Record<string, any> => (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record<string, any>; |
| 52 | +const holdsAPlaceholder = (v: unknown): boolean => { |
| 53 | + const text = typeof v === 'string' ? v : JSON.stringify(v ?? null); |
| 54 | + return ALL_PLACEHOLDERS.some((p) => text.includes(p)); |
| 55 | +}; |
| 56 | + |
| 57 | +describe('connector metadata doors: inline credentials are refused on write and withheld on read', () => { |
| 58 | + let stack: VerifyStack; |
| 59 | + let admin: string; |
| 60 | + let member: string; |
| 61 | + let ql: any; |
| 62 | + let protocol: any; |
| 63 | + let automation: any; |
| 64 | + let upstream: Server; |
| 65 | + let upstreamUrl: string; |
| 66 | + const received: IncomingHttpHeaders[] = []; |
| 67 | + let previousRef: string | undefined; |
| 68 | + |
| 69 | + const stored = async (name: string) => |
| 70 | + rowsOf(await ql.find('sys_metadata', { where: { type: 'connector', name }, context: SYSTEM })); |
| 71 | + |
| 72 | + const instance = (name: string, over: Record<string, unknown> = {}) => ({ |
| 73 | + name, |
| 74 | + label: `CCH ${name}`, |
| 75 | + type: 'api', |
| 76 | + description: 'A provider-bound instance for the connector credential pins.', |
| 77 | + provider: 'rest', |
| 78 | + providerConfig: { baseUrl: upstreamUrl }, |
| 79 | + auth: { type: 'bearer', credentialRef: CONTROL_REF }, |
| 80 | + ...over, |
| 81 | + }); |
| 82 | + |
| 83 | + /** One door read: 200, names the connector (positive control), and serves no placeholder. */ |
| 84 | + const expectWithheld = async (token: string, path: string, names: readonly string[]) => { |
| 85 | + const res = await stack.apiAs(token, 'GET', path); |
| 86 | + const text = await res.text(); |
| 87 | + expect(res.status, `${path}: ${text.slice(0, 200)}`).toBe(200); |
| 88 | + for (const name of names) expect(text.includes(name), `${path} names ${name}`).toBe(true); |
| 89 | + expect(holdsAPlaceholder(text), `${path} withholds every credential position`).toBe(false); |
| 90 | + return text; |
| 91 | + }; |
| 92 | + |
| 93 | + const dispatch = async (name: string): Promise<IncomingHttpHeaders> => { |
| 94 | + const handler = automation.resolveConnectorAction(name, 'request'); |
| 95 | + expect(handler, `${name} is materialized with its request action`).toBeTypeOf('function'); |
| 96 | + const before = received.length; |
| 97 | + const out = await handler({ method: 'GET', path: '/probe' }, {}); |
| 98 | + expect(out?.status, JSON.stringify(out)).toBe(200); |
| 99 | + expect(received.length).toBe(before + 1); |
| 100 | + return received[received.length - 1]!; |
| 101 | + }; |
| 102 | + |
| 103 | + const waitReady = async (name: string): Promise<void> => { |
| 104 | + const deadline = Date.now() + 15_000; |
| 105 | + let last = ''; |
| 106 | + while (Date.now() < deadline) { |
| 107 | + const res = await stack.apiAs(admin, 'GET', '/automation/connectors'); |
| 108 | + const json = (await res.json()) as any; |
| 109 | + const found = (json?.data?.connectors ?? []).find((c: any) => c?.name === name); |
| 110 | + last = JSON.stringify(found ?? null); |
| 111 | + if (found?.state === 'ready') return; |
| 112 | + await new Promise((r) => setTimeout(r, 100)); |
| 113 | + } |
| 114 | + throw new Error(`connector ${name} never reached ready: ${last}`); |
| 115 | + }; |
| 116 | + |
| 117 | + beforeAll(async () => { |
| 118 | + upstream = createServer((req, res) => { |
| 119 | + received.push(req.headers); |
| 120 | + res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 121 | + res.end('{"ok":true}'); |
| 122 | + }); |
| 123 | + await new Promise<void>((r) => upstream.listen(0, '127.0.0.1', () => r())); |
| 124 | + upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; |
| 125 | + previousRef = process.env[CONTROL_REF]; |
| 126 | + process.env[CONTROL_REF] = PLACEHOLDERS.resolvedRef; |
| 127 | + |
| 128 | + stack = await bootShowcase({}); |
| 129 | + ql = await stack.kernel.getServiceAsync<any>('objectql'); |
| 130 | + protocol = await stack.kernel.getServiceAsync<any>('protocol'); |
| 131 | + automation = await stack.kernel.getServiceAsync<any>('automation'); |
| 132 | + admin = await stack.signIn(); |
| 133 | + member = await stack.signUp('cch-member@connector-credentials.test'); |
| 134 | + }, 180_000); |
| 135 | + |
| 136 | + afterAll(async () => { |
| 137 | + await stack?.stop?.(); |
| 138 | + await new Promise<void>((r) => (upstream ? upstream.close(() => r()) : r())); |
| 139 | + if (previousRef === undefined) delete process.env[CONTROL_REF]; |
| 140 | + else process.env[CONTROL_REF] = previousRef; |
| 141 | + }); |
| 142 | + |
| 143 | + describe('WRITE: the save door refuses an inline secret on an instance\'s auth', () => { |
| 144 | + it.each([ |
| 145 | + ['bearer', { type: 'bearer', credentialRef: CONTROL_REF, token: PLACEHOLDERS.inlineToken }, 'token'], |
| 146 | + ['api-key', { type: 'api-key', credentialRef: CONTROL_REF, key: PLACEHOLDERS.inlineKey }, 'key'], |
| 147 | + ['basic', { type: 'basic', username: 'svc', credentialRef: CONTROL_REF, password: PLACEHOLDERS.inlinePassword }, 'password'], |
| 148 | + ['none', { type: 'none', token: PLACEHOLDERS.inlineToken }, 'token'], |
| 149 | + ] as const)('%s: 422 INVALID_METADATA located at `auth`, naming the key, and nothing is stored', async (_kind, auth, key) => { |
| 150 | + const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${REFUSED}`, instance(REFUSED, { auth })); |
| 151 | + const json = (await res.json()) as any; |
| 152 | + expect(res.status, JSON.stringify(json).slice(0, 400)).toBe(422); |
| 153 | + expect(json.code, JSON.stringify(json).slice(0, 400)).toBe('INVALID_METADATA'); |
| 154 | + const issues = (json.issues ?? json.error?.issues ?? json.details?.issues ?? []) as Array<{ path?: unknown; message?: string }>; |
| 155 | + const located = issues.find((i) => i.path === 'auth' || (Array.isArray(i.path) && i.path.join('.') === 'auth')); |
| 156 | + expect(located, JSON.stringify(issues).slice(0, 400)).toBeDefined(); |
| 157 | + expect(located!.message).toContain(`\`${key}\``); |
| 158 | + expect(holdsAPlaceholder(json), 'the refusal never echoes the value').toBe(false); |
| 159 | + expect(await stored(REFUSED)).toHaveLength(0); |
| 160 | + }); |
| 161 | + }); |
| 162 | + |
| 163 | + describe('CONTROL: a credentialRef-only instance saves and works', () => { |
| 164 | + it('saves, materializes ready, and dispatches with the reference resolved and its default headers sent', async () => { |
| 165 | + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, instance(CONTROL, { |
| 166 | + providerConfig: { baseUrl: upstreamUrl, defaultHeaders: { 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue } }, |
| 167 | + })); |
| 168 | + expect(put.status, await put.clone().text()).toBe(200); |
| 169 | + await waitReady(CONTROL); |
| 170 | + const headers = await dispatch(CONTROL); |
| 171 | + expect(headers.authorization).toBe(`Bearer ${PLACEHOLDERS.resolvedRef}`); |
| 172 | + expect(headers['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue); |
| 173 | + }); |
| 174 | + |
| 175 | + it('its read withholds the header map; a save of the served form keeps it, and dispatch still sends it', async () => { |
| 176 | + const read = await stack.apiAs(admin, 'GET', `/meta/connector/${CONTROL}`); |
| 177 | + expect(read.status).toBe(200); |
| 178 | + const served = itemOf(await read.json()); |
| 179 | + expect(served.name).toBe(CONTROL); |
| 180 | + expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl }); |
| 181 | + expect(served.auth).toEqual({ type: 'bearer', credentialRef: CONTROL_REF }); |
| 182 | + |
| 183 | + const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_'))); |
| 184 | + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, { ...body, label: 'CCH control (edited)' }); |
| 185 | + expect(put.status, await put.clone().text()).toBe(200); |
| 186 | + const [row] = await stored(CONTROL); |
| 187 | + expect(bodyOf(row).label).toBe('CCH control (edited)'); |
| 188 | + expect(bodyOf(row).providerConfig.defaultHeaders).toEqual({ 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue }); |
| 189 | + |
| 190 | + await waitReady(CONTROL); |
| 191 | + expect((await dispatch(CONTROL))['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue); |
| 192 | + }); |
| 193 | + }); |
| 194 | + |
| 195 | + describe('READ: a stored row\'s inline credential positions are withheld from every reader', () => { |
| 196 | + const seeded = () => ({ |
| 197 | + ...instance(STORED, { label: 'CCH stored (seeded)' }), |
| 198 | + providerConfig: { |
| 199 | + baseUrl: upstreamUrl, |
| 200 | + headers: { Authorization: `Bearer ${PLACEHOLDERS.headerValue}` }, |
| 201 | + defaultHeaders: { Authorization: `Bearer ${PLACEHOLDERS.defaultHeaderValue}` }, |
| 202 | + }, |
| 203 | + auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF', token: PLACEHOLDERS.inlineToken }, |
| 204 | + authentication: { type: 'bearer', token: PLACEHOLDERS.runtimeToken }, |
| 205 | + }); |
| 206 | + |
| 207 | + beforeAll(async () => { |
| 208 | + // A clean save makes the row; the system context then writes the body a |
| 209 | + // row stored before the write refusal can hold. |
| 210 | + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${STORED}`, instance(STORED, { |
| 211 | + auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF' }, |
| 212 | + })); |
| 213 | + expect(put.status, await put.clone().text()).toBe(200); |
| 214 | + const [row] = await stored(STORED); |
| 215 | + await ql.update('sys_metadata', { id: row.id, metadata: JSON.stringify(seeded()) }, { context: SYSTEM }); |
| 216 | + }); |
| 217 | + |
| 218 | + it('guard the guard: the stored row holds every seeded credential position', async () => { |
| 219 | + const [row] = await stored(STORED); |
| 220 | + const body = bodyOf(row); |
| 221 | + expect(body.label).toBe('CCH stored (seeded)'); |
| 222 | + expect(body.auth.token).toBe(PLACEHOLDERS.inlineToken); |
| 223 | + expect(body.authentication.token).toBe(PLACEHOLDERS.runtimeToken); |
| 224 | + expect(body.providerConfig.headers.Authorization).toContain(PLACEHOLDERS.headerValue); |
| 225 | + expect(body.providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue); |
| 226 | + }); |
| 227 | + |
| 228 | + it('the item read and the layered view withhold every position, for a member and for an administrator', async () => { |
| 229 | + for (const token of [member, admin]) { |
| 230 | + const text = await expectWithheld(token, `/meta/connector/${STORED}`, [STORED, 'CCH stored (seeded)']); |
| 231 | + const served = itemOf(JSON.parse(text)); |
| 232 | + expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'CCH_STORED_REF' }); |
| 233 | + expect(served.authentication).toEqual({ type: 'bearer' }); |
| 234 | + expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl }); |
| 235 | + expect(served.description).toBe('A provider-bound instance for the connector credential pins.'); |
| 236 | + expect(served.provider).toBe('rest'); |
| 237 | + expect(served.type).toBe('api'); |
| 238 | + await expectWithheld(token, `/meta/connector/${STORED}/layers`, [STORED]); |
| 239 | + } |
| 240 | + }); |
| 241 | + |
| 242 | + it('the list, both spellings, withholds every position, for a member and for an administrator', async () => { |
| 243 | + for (const token of [member, admin]) { |
| 244 | + await expectWithheld(token, '/meta/connector', [STORED, CONTROL]); |
| 245 | + await expectWithheld(token, '/meta/connectors', [STORED, CONTROL]); |
| 246 | + } |
| 247 | + }); |
| 248 | + |
| 249 | + it('the metadata protocol\'s list and item reads withhold every position', async () => { |
| 250 | + const listed = await protocol.getMetaItems({ type: 'connector' }); |
| 251 | + expect(JSON.stringify(listed).includes(STORED)).toBe(true); |
| 252 | + expect(holdsAPlaceholder(listed)).toBe(false); |
| 253 | + const item = await protocol.getMetaItem({ type: 'connector', name: STORED }); |
| 254 | + expect(JSON.stringify(item).includes('CCH stored (seeded)')).toBe(true); |
| 255 | + expect(holdsAPlaceholder(item)).toBe(false); |
| 256 | + }); |
| 257 | + |
| 258 | + it('the stored row is never redacted: redaction is a serving act', async () => { |
| 259 | + const [row] = await stored(STORED); |
| 260 | + expect(bodyOf(row).auth.token).toBe(PLACEHOLDERS.inlineToken); |
| 261 | + expect(bodyOf(row).providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue); |
| 262 | + }); |
| 263 | + }); |
| 264 | +}); |
0 commit comments