|
3 | 3 | /** |
4 | 4 | * The plugin: what it publishes, and where the credential comes from — the |
5 | 5 | * datasource's `external.credentialsRef`, dereferenced through the host's |
6 | | - * secret store at the first request, failing closed every way. |
| 6 | + * secret store at the first request, or the connector instance its |
| 7 | + * `external.connector` borrows from (ADR-0015 D7) — failing closed every way. |
7 | 8 | */ |
8 | 9 |
|
9 | 10 | import { describe, expect, it } from 'vitest'; |
10 | 11 | import type { PluginContext } from '@objectstack/core'; |
11 | 12 | import { needsExternalDriverFloor } from '@objectstack/objectql'; |
| 13 | +import { |
| 14 | + CONNECTOR_CREDENTIAL_SOURCE_SERVICE, |
| 15 | + connectorCredentialRefusal, |
| 16 | + type BorrowedConnectorCredential, |
| 17 | + type IConnectorCredentialSource, |
| 18 | +} from '@objectstack/types'; |
12 | 19 | import { createODataDriverPlugin, type DatasourceDefinitionSource, type ODataDriverPluginOptions } from './plugin.js'; |
13 | | -import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService } from './__fixtures__/recorded-service.js'; |
| 20 | +import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService, type Exchange } from './__fixtures__/recorded-service.js'; |
14 | 21 |
|
15 | 22 | type Def = ReturnType<DatasourceDefinitionSource['listDatasourceDefs']>[number]; |
16 | 23 |
|
17 | | -async function boot(options: Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>, defs: Def[]) { |
18 | | - const service = recordedService([METADATA_EXCHANGE, { url: 'People', body: page([]) }]); |
| 24 | +async function boot( |
| 25 | + options: Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>, |
| 26 | + defs: Def[], |
| 27 | + extra: { exchanges?: Exchange[]; services?: Record<string, unknown> } = {}, |
| 28 | +) { |
| 29 | + const service = recordedService(extra.exchanges ?? [METADATA_EXCHANGE, { url: 'People', body: page([]) }]); |
19 | 30 | const plugin = createODataDriverPlugin({ serviceRoot: SERVICE_ROOT, fetch: service.fetch, ...options }); |
20 | | - const services = new Map<string, unknown>([['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource]]); |
| 31 | + const services = new Map<string, unknown>([ |
| 32 | + ['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource], |
| 33 | + ...Object.entries(extra.services ?? {}), |
| 34 | + ]); |
21 | 35 | const ctx = { |
22 | 36 | registerService: (name: string, value: unknown) => { services.set(name, value); }, |
23 | 37 | getService: (name: string) => services.get(name), |
@@ -80,3 +94,139 @@ describe('createODataDriverPlugin', () => { |
80 | 94 | expect(requests).toHaveLength(0); |
81 | 95 | }); |
82 | 96 | }); |
| 97 | + |
| 98 | +/** |
| 99 | + * A lender double standing in for `@objectstack/service-automation`'s source: |
| 100 | + * `get` answers the current credential, `refresh` moves to the next one, the |
| 101 | + * shape a grant-bearing (OAuth2) instance answers through. |
| 102 | + */ |
| 103 | +function lender(answers: BorrowedConnectorCredential[], refuse?: Error) { |
| 104 | + const calls = { borrow: [] as string[], get: 0, refresh: 0 }; |
| 105 | + let current = 0; |
| 106 | + const source: IConnectorCredentialSource = { |
| 107 | + async borrow(connector) { |
| 108 | + calls.borrow.push(connector); |
| 109 | + if (refuse) throw refuse; |
| 110 | + return { |
| 111 | + connector, |
| 112 | + async get() { calls.get += 1; return answers[current]!; }, |
| 113 | + async refresh() { calls.refresh += 1; current = Math.min(current + 1, answers.length - 1); return answers[current]!; }, |
| 114 | + }; |
| 115 | + }, |
| 116 | + }; |
| 117 | + return { source, calls, services: { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: source } }; |
| 118 | +} |
| 119 | + |
| 120 | +const BORROWING: Def[] = [{ name: 'trippin', external: { connector: 'trippin_api' } }]; |
| 121 | +const bearer = (token: string, expiresAt?: number): BorrowedConnectorCredential => |
| 122 | + ({ auth: { type: 'bearer', token }, ...(expiresAt !== undefined ? { expiresAt } : {}) }); |
| 123 | + |
| 124 | +describe('createODataDriverPlugin — a credential borrowed from external.connector (ADR-0015 D7)', () => { |
| 125 | + it('sends the connector instance\'s credential, borrowed once, and never asks the secret store', async () => { |
| 126 | + const { calls, services } = lender([bearer('tok-1')]); |
| 127 | + const secrets = { resolve: async () => { throw new Error('the secret store was asked'); } }; |
| 128 | + const { plugin, requests } = await boot({ name: 'trippin', secrets }, BORROWING, { services }); |
| 129 | + await plugin.driver.find('trip_person', {}); |
| 130 | + expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-1']); |
| 131 | + expect(calls.borrow).toEqual(['trippin_api']); |
| 132 | + expect(calls.refresh).toBe(0); |
| 133 | + }); |
| 134 | + |
| 135 | + it('presents the instance\'s auth as it declares it: basic, an API key header, an API key query parameter', async () => { |
| 136 | + const basic = lender([{ auth: { type: 'basic', username: 'svc', password: 'pw' } }]); |
| 137 | + const one = await boot({ name: 'trippin' }, BORROWING, { services: basic.services }); |
| 138 | + await one.plugin.driver.find('trip_person', {}); |
| 139 | + expect(one.requests[0]!.headers.Authorization).toBe(`Basic ${Buffer.from('svc:pw').toString('base64')}`); |
| 140 | + |
| 141 | + const header = lender([{ auth: { type: 'api-key', key: 'k-1', headerName: 'APIKey' } }]); |
| 142 | + const two = await boot({ name: 'trippin' }, BORROWING, { services: header.services }); |
| 143 | + await two.plugin.driver.find('trip_person', {}); |
| 144 | + expect(two.requests[0]!.headers.APIKey).toBe('k-1'); |
| 145 | + expect(two.requests[0]!.headers.Authorization).toBeUndefined(); |
| 146 | + |
| 147 | + const query = lender([{ auth: { type: 'api-key', key: 'k-2', headerName: 'X-API-Key', paramName: 'apikey' } }]); |
| 148 | + const three = await boot({ name: 'trippin' }, BORROWING, { |
| 149 | + services: query.services, |
| 150 | + exchanges: [{ ...METADATA_EXCHANGE, url: '$metadata?apikey=k-2' }, { url: 'People?apikey=k-2', body: page([]) }], |
| 151 | + }); |
| 152 | + await three.plugin.driver.find('trip_person', {}); |
| 153 | + expect(three.requests.map((r) => r.url)).toEqual(['$metadata?apikey=k-2', 'People?apikey=k-2']); |
| 154 | + expect(three.requests[0]!.headers['X-API-Key']).toBeUndefined(); |
| 155 | + }); |
| 156 | + |
| 157 | + it('refreshes once on a 401 and retries with the credential obtained afresh', async () => { |
| 158 | + const { calls, services } = lender([bearer('tok-1'), bearer('tok-2')]); |
| 159 | + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { |
| 160 | + services, |
| 161 | + exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'expired' } }, once: true }, METADATA_EXCHANGE, { url: 'People', body: page([]) }], |
| 162 | + }); |
| 163 | + await plugin.driver.find('trip_person', {}); |
| 164 | + expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-2', 'Bearer tok-2']); |
| 165 | + expect(calls.refresh).toBe(1); |
| 166 | + }); |
| 167 | + |
| 168 | + it('refreshes a credential at its expiry before sending it', async () => { |
| 169 | + const { calls, services } = lender([bearer('tok-stale', Date.now() - 1), bearer('tok-fresh')]); |
| 170 | + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { services }); |
| 171 | + await plugin.driver.find('trip_person', {}); |
| 172 | + expect(requests[0]!.headers.Authorization).toBe('Bearer tok-fresh'); |
| 173 | + expect(calls.refresh).toBe(1); |
| 174 | + }); |
| 175 | + |
| 176 | + it('ends a second 401 as a 503 naming the instance, never the upstream\'s text', async () => { |
| 177 | + const { services } = lender([bearer('tok-1'), bearer('tok-2')]); |
| 178 | + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { |
| 179 | + services, |
| 180 | + exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'token tok-2 rejected' } } }], |
| 181 | + }); |
| 182 | + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number; code?: string }; |
| 183 | + expect(failure).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); |
| 184 | + expect(failure.message).toContain("connector instance 'trippin_api'"); |
| 185 | + expect(failure.message).not.toContain('tok-2'); |
| 186 | + expect(requests).toHaveLength(2); |
| 187 | + }); |
| 188 | + |
| 189 | + it('withholds a query-parameter API key from an upstream refusal that echoes the request URI', async () => { |
| 190 | + const key = 'k-2/x+y=='; |
| 191 | + const { services } = lender([{ auth: { type: 'api-key', key, headerName: 'X-API-Key', paramName: 'apikey' } }]); |
| 192 | + const echoed = new URL(`People?apikey=${encodeURIComponent(key)}`, SERVICE_ROOT).toString(); |
| 193 | + const { plugin } = await boot({ name: 'trippin' }, BORROWING, { |
| 194 | + services, |
| 195 | + exchanges: [ |
| 196 | + { ...METADATA_EXCHANGE, url: `$metadata?apikey=${key}` }, |
| 197 | + { url: `People?apikey=${key}`, status: 404, body: { error: { code: 'NotFound', message: `No resource at '${echoed}' (apikey=${key}, form ${new URLSearchParams({ apikey: key }).toString()})` } } }, |
| 198 | + ], |
| 199 | + }); |
| 200 | + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number }; |
| 201 | + expect(failure).toBeInstanceOf(Error); |
| 202 | + expect(failure.status).toBe(404); |
| 203 | + expect(failure.message).toContain('[credential withheld]'); |
| 204 | + for (const spelling of [key, encodeURIComponent(key), new URLSearchParams({ v: key }).toString().slice(2)]) { |
| 205 | + expect(failure.message).not.toContain(spelling); |
| 206 | + } |
| 207 | + }); |
| 208 | + |
| 209 | + const refusals: Array<[string, Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>, Def[], Record<string, unknown>, RegExp]> = [ |
| 210 | + ['no connector credential source registered', { name: 'trippin' }, BORROWING, {}, /no connector credential source is registered/], |
| 211 | + ['both credential channels', { name: 'trippin', secrets: SECRETS }, [{ name: 'trippin', external: { connector: 'trippin_api', credentialsRef: 'sys_secret:sec_trippin' } }], lender([bearer('tok-1')]).services, /names both external\.connector and external\.credentialsRef/], |
| 212 | + ['an auth scheme beside the connector', { name: 'trippin', auth: { scheme: 'bearer' } }, BORROWING, lender([bearer('tok-1')]).services, /set auth\.scheme to 'none'/], |
| 213 | + ['an instance the source refuses to lend', { name: 'trippin' }, BORROWING, lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422)).services, /'trippin_api' is disabled/], |
| 214 | + ['a credential the handle cannot answer', { name: 'trippin' }, BORROWING, { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: { borrow: async (connector: string) => ({ connector, get: async () => { throw connectorCredentialRefusal("connector instance 'trippin_api': credential not set", 'SERVICE_UNAVAILABLE', 503); }, refresh: async () => { throw new Error('unreached'); } }) } }, /credential not set/], |
| 215 | + ]; |
| 216 | + |
| 217 | + it.each(refusals)('fails closed, sending nothing, on %s', async (_label, options, defs, services, message) => { |
| 218 | + const { plugin, requests } = await boot(options, defs, { services }); |
| 219 | + await expect(plugin.driver.find('trip_person', {})).rejects.toThrow(message); |
| 220 | + expect(requests).toHaveLength(0); |
| 221 | + }); |
| 222 | + |
| 223 | + it('answers a lend refusal without its 422, so the engine reads it as the connection\'s condition, and asks again next request', async () => { |
| 224 | + const refused = lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422)); |
| 225 | + const { plugin } = await boot({ name: 'trippin' }, BORROWING, { services: refused.services }); |
| 226 | + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number }; |
| 227 | + expect(failure).toBeInstanceOf(Error); |
| 228 | + expect(failure.status).toBeUndefined(); |
| 229 | + await plugin.driver.find('trip_person', {}).catch(() => {}); |
| 230 | + expect(refused.calls.borrow).toEqual(['trippin_api', 'trippin_api']); |
| 231 | + }); |
| 232 | +}); |
0 commit comments