Skip to content

Commit 00c9a0b

Browse files
fix(service-datasource): an adopted plugin driver's failed-credentials verdict clears on the next granted borrow, and the connector path withholds the form-encoded credential spelling (#27990)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi13dWZTobFkZz74JNwvgxHn)_ **Fable review owed.** `Clause-②: yes (widening)` (a new optional member on a published `@objectstack/types` contract), so it lands after a contract review PASS on the current head. Fixes #27919 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** 插件驱动(driver-odata)的数据源若在启动时因为连接器实例借不出凭据(未声明、已停用、没有 `auth`)被判 `failed-credentials`,现在实例修好后,下一次出借该实例凭据成功时,这个判定和引擎的不可用标记就自动撤销,不用重启。出借方(自动化服务)新增可选的 `onLent(listener)`,每次成功出借都按实例名通知;数据源连接服务订阅它。另外,连接器自身调用路径的 `credentialForms` 改为直接用 `borrowedCredentialForms`,补上了表单编码(空格写成 `+`)这种写法的脱敏。 - **为什么改:** #27819 契约复审(记录 6514494060)的两条 should-fix:判定在修复后仍挂到重启,Setup → Datasources 一直显示失败;两份凭据写法清单重复,且连接器路径漏了表单编码。 - **风险与回滚:** 只撤销「同一个实例」的判定,别的实例出借成功不影响(有测试)。驱动若已被移除、或数据源之后又被重连或删除,都不会被改写。没有出借方,或出借方没有 `onLent`,行为与现在相同,降级启动横幅仍写「需重启」。回滚即 revert。 - **先看:** `packages/services/service-datasource/src/datasource-connection-service.ts` 的已注册守卫、`subscribeToLends`、`retractOnLend`;`packages/types/src/connector-credential-source.ts` 的 `onLent`;`packages/services/service-automation/src/connector-credentials.ts` 的 `credentialForms`。 - **两分钟验证:** `pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-connector-credential.test.ts`,预期 24 个用例全绿,其中新的 5 个在 "clears on the next granted borrow" 下。 - **需要您做的:** 无。契约复审 PASS 后落地。 ## What changes **The contract** (`packages/types/src/connector-credential-source.ts`): `IConnectorCredentialSource` gains an optional `onLent(listener)`. The listener is called with the connector instance's name each time `borrow` grants a handle, whoever asked (a driver's request, a connect, a probe); it returns the unsubscribe; a listener's throw never fails the borrow. A borrow judges the declaration, so a grant proves the instance can lend now. The docblock of `borrowedCredentialForms` now says the connector call path takes its list from it. **The lender** (`packages/services/service-automation/src/connector-credential-source.ts`): implements `onLent`. A granted borrow is announced after the declaration check passes and before the handle is built; a refusal is never announced; each listener is called in its own try/catch. **The borrower** (`packages/services/service-datasource/src/datasource-connection-service.ts`): when the already-registered guard (the one connect an adopted plugin driver reaches) records `failed-credentials`, the service subscribes to the lender's `onLent` (once per lender) and keeps the datasource's name with the instance it names and the reason recorded. On a grant for THAT instance, `retractOnLend` re-records the datasource `already-registered`, which clears the engine's unavailable mark, but only while the retained state is still that verdict and the plugin's driver is still registered. A grant for any other instance leaves it standing. A connect, reconnect or disconnect of the datasource drops the entry (it records its own verdict), and `disconnectAll` releases the subscriptions. With no lender, or a lender without `onLent`, nothing changes: the verdict stays until a restart. `handleFailure` takes an optional `recovery` sentence for the degraded-boot banner, so the guard's banner says the first granted borrow clears it; every other caller keeps the "reachable AND the server is restarted" text. **The connector call path** (`packages/services/service-automation/src/connector-credentials.ts`): `credentialForms(value, username?)` keeps its signature and its three callers, and now delegates to `borrowedCredentialForms` (a `basic` auth when a username is given, else a bearer one). Its local base64 helper is gone. The list gains the form encoding `URLSearchParams` writes. **Changeset:** `patch` on `@objectstack/types`, `@objectstack/service-automation` and `@objectstack/service-datasource` (`Metadata-protocol: none` takes patch). ## Why The two non-blocking should-fixes of the delta contract review of #27819 (record 6514494060, VERDICT PASS on `3351ae35`), filed as this card under epic #27261. The card preferred clearing the verdict on the next successful borrow over rewording the banner; this does that, and the banner states which recovery applies. ## Verification On the branch head `016e23cd` (origin/main `9900366e` merged in; its incoming commits touch none of these files, and the `service-automation` suite was rerun after the merge: 219 files, 2824 passed): - **New tests.** `service-datasource` `datasource-connector-credential.test.ts` (5): on a real `ObjectQL` engine and the real `DatasourceAdminServicePlugin`, a disabled instance records `failed-credentials` with the engine's mark; a grant for another instance (`salesforce_crm`) leaves both; after the repair, a grant for `sap_s4` re-records `already-registered`, availability `available`, no reason, no mark, driver still registered. One subscription per lender for two waiting datasources, released by `disconnectAll`. A disconnect before the grant resurrects nothing. A driver evicted before the grant keeps the verdict. The degraded-boot banner names the next granted borrow with a lender that has `onLent`, and keeps the restart sentence with one that lacks it. `service-automation` `connector-credential-source.test.ts` (2): `onLent` names the instance on a grant, never on a refusal (disabled, undeclared), and stops after the unsubscribe; a throwing listener does not fail the borrow and later listeners still run. `connector-credential-door.test.ts` (1): an api-key instance whose upstream 401 body echoes the form-encoded query string; no form-encoded, percent-encoded or raw spelling reaches the degraded reason, the credential listing or the logs. - **Ablations, each red then restored** (`git status --porcelain` empty after each): `retractOnLend` made a no-op: the retraction case fails; the connector-name match removed: the same case fails (the other-instance grant clears it); the form-encoded spelling filtered out of `credentialForms`: the new door case fails. - **Typecheck:** `@objectstack/types`, `@objectstack/service-automation` (with `check:test-typecheck`), `@objectstack/service-datasource`: exit 0. **Lint:** eslint on the changed files, clean. - **Suites:** `@objectstack/types` 33 files, 832 passed; `@objectstack/service-datasource` 65 files, 1069 passed; `@objectstack/service-automation` 219 files, 2821 passed; `@objectstack/driver-odata` (the borrowing presenter, unchanged) 7 files, 727 passed. - **Dispatch gates:** `dispatch-gates --commands` derived 62 families on the merged head; all run with exit codes recorded before any pipe; `--ran`: 62 derived, 62 run, 0 NOT-MEASURED (a DERIVED zero). On the first pass `check-plugin-teardown-shape --self-test` and `check:dual-build-cjs-loads` exited 3 (fixture commit not fetched, packages not built) and passed once those prerequisites were met. ## Acceptance notes - **`credentialForms` is kept as the connector path's adapter, not removed.** Its callers hold a value and an optional basic username, not a `BorrowedConnectorAuth`, and one of them (the credential door's write) has no `auth` at all. The adapter is two lines over `borrowedCredentialForms`, so there is one list of spellings. - **The D2 gate is unchanged.** A plugin-driver datasource no object binds to gets no connect verdict at all, as before, so there is nothing to retract. - **A successful borrow is a declaration verdict, as the guard's was.** It does not prove the credential value is set; the handle still refuses a missing value per request, which is the guard's own scope. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the changed files is clean. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2eVFL4vpCbzypgy7JU)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2eVFL4vpCbzypgy7JU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4694365 commit 00c9a0b

8 files changed

Lines changed: 365 additions & 23 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/service-datasource': patch
5+
---
6+
7+
fix: a plugin-driver datasource refused at boot for its connector instance's credential recovers on the next granted borrow, and the connector call path withholds the form-encoded spelling of a credential
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/service-datasource`**: a datasource served by a plugin driver (driver-odata) that names `external.connector`, whose instance could not lend at boot (undeclared, disabled, no `auth`), was recorded `failed-credentials` and marked unavailable until a restart, although the driver borrows again on every request and served as soon as the instance was repaired. The first borrow the lender grants for that instance now retracts the verdict and the engine's unavailable mark, so Setup → Datasources shows it available again with no restart. A grant for any other instance leaves it standing. The degraded-boot banner says which recovery applies; with no lender registered the verdict still lasts until a restart.
13+
- **`@objectstack/types`**: `IConnectorCredentialSource` gains an optional `onLent(listener)`, called with the instance's name on every granted borrow and returning the unsubscribe. A source without it keeps working; nothing then retracts such a verdict.
14+
- **`@objectstack/service-automation`**: the connector credential source implements `onLent`. The connector call path's `credentialForms` now takes its list from `borrowedCredentialForms`, so an upstream that echoes an API key in a form-encoded query string (a space written `+`) has that spelling withheld from the degraded reason, the stored status and the logs, as a datasource borrowing the same credential already had.

‎packages/services/service-automation/src/connector-credential-door.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,4 +682,43 @@ describe('[#27237] contract review: an encoded spelling of the value is withheld
682682
}
683683
}
684684
});
685+
686+
// [ADR-0015 D7] The connector path takes its spellings from the shared
687+
// `borrowedCredentialForms`, so the form encoding `URLSearchParams` writes
688+
// (a space as `+`, which `encodeURIComponent` spells `%20`) is withheld here
689+
// as it is for a datasource borrowing the same credential.
690+
it('an api-key instance whose upstream echoes the form-encoded query string: no spelling reaches the reason, the row or a log', async () => {
691+
const formEncoded = new URLSearchParams({ v: ENCODABLE }).toString().slice(2);
692+
expect(formEncoded).not.toBe(encodeURIComponent(ENCODABLE));
693+
expect(credentialForms(ENCODABLE)).toContain(formEncoded);
694+
695+
const out = captureOutput();
696+
const provider = fakeProvider();
697+
const factory: ConnectorProviderFactory = (ctx) => {
698+
provider.state.materialized.push(ctx);
699+
const key = ctx.auth?.type === 'api-key' ? ctx.auth.key : '';
700+
const echo = `GET https://erp.example/orders?${new URLSearchParams({ api_key: key })} was refused`;
701+
return {
702+
def: { name: ctx.name, label: ctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] } as unknown as Connector,
703+
handlers: { request: async () => ({ status: 401, ok: false, body: { message: echo } }) },
704+
};
705+
};
706+
const erp = { ...billing('erp'), auth: { type: 'api-key', paramName: 'api_key', credentialRef: REF } };
707+
const { engine, call } = await boot([erp], { provider: { ...provider, factory } });
708+
709+
await engine.setConnectorCredential({ ref: REF, value: ENCODABLE, context: ADMIN });
710+
expect(await call('erp')).toMatchObject({ status: 401 });
711+
const reason = engine.getConnectorDegradedReason('erp') ?? '';
712+
expect(reason).toContain('[credential withheld]');
713+
714+
for (const [where, text] of [
715+
['reason on a call', reason],
716+
['listing after the call', JSON.stringify(await engine.listConnectorCredentialRefs())],
717+
['logs', out.text()],
718+
] as const) {
719+
for (const form of [formEncoded, encodeURIComponent(ENCODABLE), ENCODABLE]) {
720+
expect(text.includes(form), `${where}: a spelling of the value`).toBe(false);
721+
}
722+
}
723+
});
685724
});

‎packages/services/service-automation/src/connector-credential-source.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,42 @@ describe('[ADR-0015 D7] createConnectorCredentialSource — the refusals of a ha
259259
expect(reads).toBe(1);
260260
});
261261
});
262+
263+
describe('[ADR-0015 D7] createConnectorCredentialSource — onLent announces each granted borrow', () => {
264+
const entry = sap();
265+
const source = (readDeclared: () => Promise<unknown[]>) => createConnectorCredentialSource({
266+
readDeclared,
267+
chain: async () => ({ env: () => DUMMY_VALUE }),
268+
vaultWired: () => false,
269+
generation: () => 0,
270+
});
271+
272+
it('names the instance on a lend, never on a refusal, and stops after the unsubscribe', async () => {
273+
let declared: unknown[] = [{ ...entry, enabled: false }];
274+
const lender = source(async () => declared);
275+
const lent: string[] = [];
276+
const unsubscribe = lender.onLent!((connector) => lent.push(connector));
277+
278+
await expect(lender.borrow('sap_s4')).rejects.toMatchObject({ status: 422 });
279+
await expect(lender.borrow('not_declared')).rejects.toMatchObject({ status: 422 });
280+
expect(lent).toEqual([]);
281+
282+
declared = [entry];
283+
await lender.borrow('sap_s4');
284+
expect(lent).toEqual(['sap_s4']);
285+
286+
unsubscribe();
287+
await lender.borrow('sap_s4');
288+
expect(lent).toEqual(['sap_s4']);
289+
});
290+
291+
it('grants the borrow when a listener throws', async () => {
292+
const lender = source(async () => [entry]);
293+
const later: string[] = [];
294+
lender.onLent!(() => { throw new Error('listener fault'); });
295+
lender.onLent!((connector) => later.push(connector));
296+
const handle = await lender.borrow('sap_s4');
297+
expect(handle.connector).toBe('sap_s4');
298+
expect(later).toEqual(['sap_s4']);
299+
});
300+
});

‎packages/services/service-automation/src/connector-credential-source.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
* path runs, or on a `refresh()`. The grant-bearing kinds ADR-0143 adds answer
3030
* through the same handle, per dispatch, when they land.
3131
*
32+
* Each granted borrow is announced to the source's `onLent` subscribers by
33+
* the instance's name alone, so a datasource the boot recorded as unable to
34+
* borrow is re-judged the moment its instance lends again (the datasource
35+
* connection service subscribes).
36+
*
3237
* ⛔ A value leaves this module only as the return of a handle's `get` /
3338
* `refresh`. Every refusal names the instance and the reference, never a
3439
* value, and a host resolver's own message is not interpolated.
@@ -195,10 +200,27 @@ export function createConnectorCredentialSource(deps: ConnectorCredentialSourceD
195200
return item as Lendable;
196201
};
197202

203+
const lentListeners = new Set<(connector: string) => void>();
204+
/** Tell every `onLent` subscriber; a listener's throw stays its own. */
205+
const announceLent = (connector: string): void => {
206+
for (const listener of [...lentListeners]) {
207+
try {
208+
listener(connector);
209+
} catch {
210+
/* the borrow was granted either way */
211+
}
212+
}
213+
};
214+
198215
return {
216+
onLent(listener: (connector: string) => void): () => void {
217+
lentListeners.add(listener);
218+
return () => void lentListeners.delete(listener);
219+
},
199220
async borrow(connector: string): Promise<ConnectorCredentialHandle> {
200221
const refusal = lendRefusal(connector, findDeclared(await readDeclared(connector), connector));
201222
if (refusal) throw connectorCredentialRefusal(refusal, 'VALIDATION_ERROR', 422);
223+
announceLent(connector);
202224

203225
let held: { generation: number; credential: BorrowedConnectorCredential } | undefined;
204226
let inflight: Promise<BorrowedConnectorCredential> | undefined;

‎packages/services/service-automation/src/connector-credentials.ts‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* a status and a revision, never the value.
2828
*/
2929

30+
import { borrowedCredentialForms } from '@objectstack/types';
3031
import { defaultEnvCredentialResolver, type CredentialResolver } from './credential-resolver.js';
3132

3233
/** The object a connector credential lives in (`sys-connector-credential.object.ts`). */
@@ -422,27 +423,22 @@ export type WithheldCredential = string | readonly string[] | undefined;
422423
/** The shortest spelling a scrub withholds — a shorter one would blank ordinary words. */
423424
const MIN_WITHHELD_LENGTH = 4;
424425

425-
function base64Of(text: string): string {
426-
const bytes = new TextEncoder().encode(text);
427-
let binary = '';
428-
for (const byte of bytes) binary += String.fromCharCode(byte);
429-
return btoa(binary);
430-
}
431-
432426
/**
433-
* Every spelling of `value` an upstream or a client library may echo back:
434-
* the value itself; URL-encoded, as an api-key sent as a query parameter
435-
* travels in the request URL; base64; and, given the `basic` `username`,
436-
* base64(`username:value`), the `Authorization: Basic` header. A base64 form
437-
* is kept without its `=` padding, so an echo that drops the padding is
438-
* withheld too. Longest first, so a longer form is replaced before a shorter
439-
* one inside it.
427+
* Every spelling of `value` an upstream or a client library may echo back —
428+
* the one rule `borrowedCredentialForms` (`@objectstack/types`) holds for a
429+
* datasource borrowing this credential, so the connector's own call path and
430+
* the borrowing driver withhold the same list: the value itself; URL-encoded
431+
* both ways (`encodeURIComponent`, and the form encoding `URLSearchParams`
432+
* writes), as an api-key sent as a query parameter travels in the request
433+
* URL; base64; and, given the `basic` `username`, base64(`username:value`),
434+
* the `Authorization: Basic` header. Base64 forms are unpadded and the list
435+
* is longest first.
440436
*/
441437
export function credentialForms(value: string | undefined, username?: string): string[] {
442438
if (!value) return [];
443-
const forms = new Set<string>([value, encodeURIComponent(value), base64Of(value).replace(/=+$/, '')]);
444-
if (username !== undefined) forms.add(base64Of(`${username}:${value}`).replace(/=+$/, ''));
445-
return [...forms].filter((f) => f.length >= MIN_WITHHELD_LENGTH).sort((a, b) => b.length - a.length);
439+
return borrowedCredentialForms(
440+
username !== undefined ? { type: 'basic', username, password: value } : { type: 'bearer', token: value },
441+
);
446442
}
447443

448444
function withheldForms(secret: WithheldCredential): readonly string[] {

‎packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts‎

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, vi } from 'vitest';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import { ObjectQL } from '@objectstack/objectql';
55
import {
66
CONNECTOR_CREDENTIAL_SOURCE_SERVICE,
@@ -393,6 +393,137 @@ describe('[ADR-0015 D7] a plugin driver adopted from its driver.<name> service i
393393
});
394394
});
395395

396+
/**
397+
* A lender double whose declaration can be repaired, with `onLent`, as the
398+
* automation service's source has it: a borrow of a declared, enabled
399+
* instance is granted and announced to the subscribers.
400+
*/
401+
function repairableLender(disabled: Set<string>) {
402+
const listeners = new Set<(connector: string) => void>();
403+
const source: IConnectorCredentialSource = {
404+
async borrow(connector) {
405+
if (disabled.has(connector)) {
406+
throw connectorCredentialRefusal(
407+
`connector instance '${connector}' (named by this datasource's external.connector) is disabled (enabled: false).`,
408+
'VALIDATION_ERROR',
409+
422,
410+
);
411+
}
412+
for (const listener of listeners) listener(connector);
413+
const fixed: BorrowedConnectorCredential = { auth: { type: 'bearer', token: 'static-key' } };
414+
return { connector, get: async () => fixed, refresh: async () => fixed };
415+
},
416+
onLent(listener) {
417+
listeners.add(listener);
418+
return () => void listeners.delete(listener);
419+
},
420+
};
421+
return { source, listeners };
422+
}
423+
424+
describe('[ADR-0015 D7] an adopted plugin driver\'s failed-credentials verdict clears on the next granted borrow', () => {
425+
const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE';
426+
let saved: string | undefined;
427+
beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV]; });
428+
afterEach(() => {
429+
if (saved === undefined) delete process.env[ENV];
430+
else process.env[ENV] = saved;
431+
});
432+
433+
it('retracts the verdict and the unavailable mark once the repaired instance lends — and only for that instance', async () => {
434+
const disabled = new Set(['sap_s4']);
435+
const { source } = repairableLender(disabled);
436+
const { ql, connection } = await adoptedPluginDriver(source);
437+
const result = await connection.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } });
438+
expect(result.status).toBe('failed-credentials');
439+
expect(ql.listUnavailableDatasources()).toEqual([expect.objectContaining({ name: 'sap_orders' })]);
440+
441+
// A grant for another instance proves nothing about this one.
442+
await source.borrow('salesforce_crm');
443+
expect(connection.getConnectionState('sap_orders')).toMatchObject({ status: 'failed-credentials', availability: 'failed' });
444+
expect(ql.listUnavailableDatasources()).toHaveLength(1);
445+
446+
// The instance is repaired, and the driver's next request borrows it.
447+
disabled.delete('sap_s4');
448+
await source.borrow('sap_s4');
449+
expect(connection.getConnectionState('sap_orders')).toMatchObject({ status: 'already-registered', availability: 'available' });
450+
expect(connection.getConnectionState('sap_orders')?.reason).toBeUndefined();
451+
expect(ql.listUnavailableDatasources()).toEqual([]);
452+
expect(ql.getDriverByName('sap_orders')).toBeDefined();
453+
});
454+
455+
it('subscribes once per lender, however many datasources wait on it', async () => {
456+
const { source, listeners } = repairableLender(new Set(['sap_s4']));
457+
const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source);
458+
for (const name of ['sap_orders', 'sap_invoices']) {
459+
engine.drivers.set(name, { name } as IDataDriver);
460+
await svc.connect({ ...sapOrders, name }, { context: { origin: 'code', trigger: 'declared-auto' } });
461+
}
462+
expect(listeners.size).toBe(1);
463+
await svc.disconnectAll();
464+
expect(listeners.size).toBe(0);
465+
});
466+
467+
it('leaves a verdict standing that a disconnect or a later connect replaced', async () => {
468+
const disabled = new Set(['sap_s4']);
469+
const { source } = repairableLender(disabled);
470+
const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source);
471+
engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver);
472+
await svc.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } });
473+
await svc.disconnect('sap_orders');
474+
disabled.delete('sap_s4');
475+
await source.borrow('sap_s4');
476+
// Nothing resurrected for a datasource that was removed.
477+
expect(svc.getConnectionState('sap_orders')).toBeUndefined();
478+
});
479+
480+
it('leaves the verdict when the driver was evicted since, rather than reporting a driver that is gone', async () => {
481+
const disabled = new Set(['sap_s4']);
482+
const { source } = repairableLender(disabled);
483+
const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source);
484+
engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver);
485+
await svc.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } });
486+
engine.drivers.delete('sap_orders');
487+
disabled.delete('sap_s4');
488+
await source.borrow('sap_s4');
489+
expect(svc.getConnectionState('sap_orders')?.status).toBe('failed-credentials');
490+
});
491+
492+
it('says in the degraded-boot banner that the next granted borrow clears it, and a restart where nothing can', async () => {
493+
process.env[ENV] = '1';
494+
const banners = async (source: IConnectorCredentialSource) => {
495+
const warnings: string[] = [];
496+
const engine = fakeEngine();
497+
engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver);
498+
const svc = new DatasourceConnectionService({
499+
factory: () => ({ supports: () => false, create: vi.fn() }) as unknown as IDatasourceDriverFactory,
500+
engine: () => engine,
501+
connectorCredentials: () => source,
502+
logger: { info() {}, debug() {}, error() {}, warn: (m: string) => void warnings.push(m) } as never,
503+
});
504+
const realWrite = process.stderr.write;
505+
(process.stderr as { write: unknown }).write = () => true;
506+
try {
507+
const result = await svc.connect(sapOrders, { objects: ['sap_order'], context: { origin: 'code', trigger: 'declared-auto' } });
508+
expect(result.status).toBe('failed-credentials');
509+
} finally {
510+
(process.stderr as { write: unknown }).write = realWrite;
511+
}
512+
return warnings.join('\n');
513+
};
514+
515+
const retractable = await banners(repairableLender(new Set(['sap_s4'])).source);
516+
expect(retractable).toContain('DEGRADED BOOT');
517+
expect(retractable).toContain("the first borrow granted for 'sap_s4' clears this verdict");
518+
expect(retractable).not.toContain('server is restarted');
519+
520+
// A lender without onLent: nothing can retract the verdict, and the banner keeps the restart.
521+
const { onLent: _omitted, ...withoutOnLent } = repairableLender(new Set(['sap_s4'])).source;
522+
const standing = await banners(withoutOnLent);
523+
expect(standing).toContain('until it is reachable AND the server is restarted');
524+
});
525+
});
526+
396527
describe('[ADR-0015 D7] borrowConnectorCredential', () => {
397528
it('never throws: a source that throws a non-Error comes back as a reason', async () => {
398529
const source = { borrow: async () => { throw 'boom'; } } as unknown as IConnectorCredentialSource;

0 commit comments

Comments
 (0)