|
4 | 4 | // `provider: 'openapi'` connector instance from a spec document + resolved auth. |
5 | 5 |
|
6 | 6 | import { describe, it, expect } from 'vitest'; |
| 7 | +import { LiteKernel } from '@objectstack/core'; |
7 | 8 | import type { ConnectorProviderContext } from '@objectstack/spec/integration'; |
8 | 9 | import { isConnectorUpstreamUnavailable, RetryConfigSchema } from '@objectstack/spec/integration'; |
| 10 | +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; |
| 11 | +import { ConnectorOpenApiPlugin } from './connector-openapi-plugin.js'; |
9 | 12 | import { createOpenApiProviderFactory, OPENAPI_PROVIDER_KEY } from './openapi-provider.js'; |
10 | 13 |
|
11 | 14 | const petstore = { |
@@ -322,3 +325,143 @@ describe('openapi provider — refuses a document that is not OpenAPI 3.x', () = |
322 | 325 | } |
323 | 326 | }); |
324 | 327 | }); |
| 328 | + |
| 329 | +// ── providerConfig.include — a declarative operation allowlist ───────────── |
| 330 | +// |
| 331 | +// The same allowlist meaning as the mcp provider's `include`: only the listed |
| 332 | +// operations become actions. An entry is an operation's action key — its |
| 333 | +// operationId, or the generated method_path key when it has none — and an |
| 334 | +// entry naming an operation the document does not carry is a plain (fatal) |
| 335 | +// configuration fault at materialization, never ignored. |
| 336 | + |
| 337 | +describe('openapi provider — providerConfig.include', () => { |
| 338 | + const crm = { |
| 339 | + openapi: '3.0.3', |
| 340 | + info: { title: 'CRM', version: '1.0.0' }, |
| 341 | + servers: [{ url: 'https://crm.example.com' }], |
| 342 | + paths: { |
| 343 | + '/customers': { |
| 344 | + get: { operationId: 'listCustomers', responses: { '200': { description: 'ok' } } }, |
| 345 | + post: { operationId: 'createCustomer', responses: { '201': { description: 'created' } } }, |
| 346 | + }, |
| 347 | + '/customers/{id}': { |
| 348 | + // No operationId: the action key is the generated method_path slug. |
| 349 | + delete: { responses: { '204': { description: 'gone' } } }, |
| 350 | + }, |
| 351 | + }, |
| 352 | + }; |
| 353 | + |
| 354 | + async function refusal(result: unknown): Promise<Error> { |
| 355 | + const err = await Promise.resolve(result).then( |
| 356 | + () => { throw new Error('expected rejection'); }, |
| 357 | + (e: unknown) => e, |
| 358 | + ); |
| 359 | + expect(err).toBeInstanceOf(Error); |
| 360 | + expect(isConnectorUpstreamUnavailable(err)).toBe(false); |
| 361 | + return err as Error; |
| 362 | + } |
| 363 | + |
| 364 | + it('without include, every operation is an action (control)', async () => { |
| 365 | + const { def } = await createOpenApiProviderFactory()(ctx({ providerConfig: { spec: crm } })); |
| 366 | + expect(def.actions?.map((a) => a.key)).toEqual(['listCustomers', 'createCustomer', 'delete_customers_id']); |
| 367 | + }); |
| 368 | + |
| 369 | + it("include: ['listCustomers'] exposes exactly that action", async () => { |
| 370 | + const { def, handlers } = await createOpenApiProviderFactory()( |
| 371 | + ctx({ providerConfig: { spec: crm, include: ['listCustomers'] } }), |
| 372 | + ); |
| 373 | + expect(def.actions?.map((a) => a.key)).toEqual(['listCustomers']); |
| 374 | + expect(Object.keys(handlers)).toEqual(['listCustomers']); |
| 375 | + }); |
| 376 | + |
| 377 | + it('names an operation without an operationId by its generated method_path key', async () => { |
| 378 | + const { def } = await createOpenApiProviderFactory()( |
| 379 | + ctx({ providerConfig: { spec: crm, include: ['delete_customers_id', 'createCustomer'] } }), |
| 380 | + ); |
| 381 | + // Document order, not include order — the allowlist filters, it does not reorder. |
| 382 | + expect(def.actions?.map((a) => a.key)).toEqual(['createCustomer', 'delete_customers_id']); |
| 383 | + }); |
| 384 | + |
| 385 | + it("include: ['nope'] refuses at materialization, naming the entry and the document's keys", async () => { |
| 386 | + const err = await refusal( |
| 387 | + createOpenApiProviderFactory()(ctx({ providerConfig: { spec: crm, include: ['listCustomers', 'nope'] } })), |
| 388 | + ); |
| 389 | + expect(err.message).toContain("connector 'pets' providerConfig.include names 'nope', which the OpenAPI document does not carry"); |
| 390 | + expect(err.message).toContain("'listCustomers', 'createCustomer', 'delete_customers_id'"); |
| 391 | + expect(err.message).not.toContain("'listCustomers', which"); |
| 392 | + }); |
| 393 | + |
| 394 | + it('an operation that has an operationId is not named by its method_path key', async () => { |
| 395 | + const err = await refusal( |
| 396 | + createOpenApiProviderFactory()(ctx({ providerConfig: { spec: crm, include: ['get_customers'] } })), |
| 397 | + ); |
| 398 | + expect(err.message).toContain("names 'get_customers', which the OpenAPI document does not carry"); |
| 399 | + }); |
| 400 | + |
| 401 | + it('refuses an include that is not an array of non-empty strings, before the document is fetched', async () => { |
| 402 | + let fetched = 0; |
| 403 | + const fetchImpl = (async () => { |
| 404 | + fetched++; |
| 405 | + return { ok: true, status: 200, json: async () => crm } as unknown as Response; |
| 406 | + }) as unknown as typeof fetch; |
| 407 | + const factory = createOpenApiProviderFactory({ fetchImpl }); |
| 408 | + for (const include of ['listCustomers', ['listCustomers', 7], [''], { listCustomers: true }, null]) { |
| 409 | + const err = await refusal(factory(ctx({ providerConfig: { spec: 'https://crm.example.com/openapi.json', include } }))); |
| 410 | + expect(err.message, JSON.stringify(include)).toContain("connector 'pets' providerConfig.include must be an array of operation keys"); |
| 411 | + } |
| 412 | + expect(fetched).toBe(0); |
| 413 | + }); |
| 414 | + |
| 415 | + it('an empty include admits no operation, as an empty mcp include admits no tool', async () => { |
| 416 | + const { def } = await createOpenApiProviderFactory()(ctx({ providerConfig: { spec: crm, include: [] } })); |
| 417 | + expect(def.actions).toEqual([]); |
| 418 | + }); |
| 419 | + |
| 420 | + describe('through a real boot (AutomationServicePlugin + ConnectorOpenApiPlugin)', () => { |
| 421 | + async function boot(include: unknown) { |
| 422 | + const declared = [ |
| 423 | + { name: 'crm_api', label: 'CRM API', type: 'api', provider: 'openapi', providerConfig: { spec: crm, include } }, |
| 424 | + ]; |
| 425 | + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); |
| 426 | + kernel.use(new AutomationServicePlugin()); |
| 427 | + kernel.use({ |
| 428 | + name: 'test.connector-metadata', |
| 429 | + type: 'standard', |
| 430 | + version: '1.0.0', |
| 431 | + dependencies: ['com.objectstack.service-automation'], |
| 432 | + async init(c: { registerService(name: string, service: unknown): void }) { |
| 433 | + c.registerService('objectql', { |
| 434 | + registry: { listItems: (type: string) => (type === 'connector' ? declared : []) }, |
| 435 | + }); |
| 436 | + }, |
| 437 | + async start() {}, |
| 438 | + } as never); |
| 439 | + kernel.use(new ConnectorOpenApiPlugin()); |
| 440 | + return kernel; |
| 441 | + } |
| 442 | + |
| 443 | + it('a declared include reaches GET /connectors as exactly the listed action', async () => { |
| 444 | + const kernel = await boot(['listCustomers']); |
| 445 | + try { |
| 446 | + await kernel.bootstrap(); |
| 447 | + const engine = kernel.getService<AutomationEngine>('automation'); |
| 448 | + const crmApi = engine.getConnectorDescriptors().find((d) => d.name === 'crm_api'); |
| 449 | + expect(crmApi?.state).toBe('ready'); |
| 450 | + expect(crmApi?.actions.map((a) => a.key)).toEqual(['listCustomers']); |
| 451 | + } finally { |
| 452 | + await kernel.shutdown().catch(() => {}); |
| 453 | + } |
| 454 | + }); |
| 455 | + |
| 456 | + it('an unknown include entry fails the boot instead of degrading', async () => { |
| 457 | + const kernel = await boot(['nope']); |
| 458 | + try { |
| 459 | + await expect(kernel.bootstrap()).rejects.toThrow(/providerConfig\.include names 'nope'/); |
| 460 | + const engine = kernel.getService<AutomationEngine>('automation'); |
| 461 | + expect(engine.getConnectorDegradedReason('crm_api')).toBeUndefined(); |
| 462 | + } finally { |
| 463 | + await kernel.shutdown().catch(() => {}); |
| 464 | + } |
| 465 | + }); |
| 466 | + }); |
| 467 | +}); |
0 commit comments