Skip to content

Commit 5cdaddb

Browse files
fix(service-datasource)!: a datasource update whose new configuration fails to connect stores none of it (#29981)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBLN3AWdL2vTrzytbTbs93mx)_ Fixes #29879 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs/**` teaches what a datasource update stores when its rebuild fails, or what a blank `secret` does at the admin doors; the fail-closed paragraph in `external-datasources.mdx` (§4 Credentials) stays true. **BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`): a whitespace-only `secret` is refused. ## 维护者速读 - **改了什么**:`PATCH /api/v1/datasources/NAME` 在新配置连不上、旧连接池被保留时,现在什么都不落盘:数据源记录、`external.credentialsRef`、同一次 PATCH 里的其它键都保持原样,为这次 PATCH 新建的 sys_secret 行会被删掉。接口回答不变(200、`status: "error"`、「新配置未生效」),现在这句话是真的。另外,全空白的 `secret`(`" "`、制表符加换行)和空串一样在入口回 400「Nothing was written」,用的是同一个守卫和同一句话。 - **为什么改**:以前先存记录、换凭据、删旧凭据,再重建连接池;重建失败时旧池继续服务,接口说「未生效」,但下次重启读到的是失败的新凭据,能用的旧凭据已经被删了(#29879,QA 记录 #29877)。 - **风险与回滚**:中低。成功路径的结果不变(新凭据存盘并生效,旧凭据退役),只是顺序从「先存后连」变成「先连后存」,失败时不存。自己实现 `reregisterPool` 的宿主不回报结果时行为与以前相同。回滚即 revert。 - **先看**:`packages/services/service-datasource/src/datasource-admin-service.ts` 的 `updateDatasource` 尾部(`previousPoolKept` 分支、`putDatasourceRecord` 之后才删旧凭据)与 `assertSecretNotEmpty`;`datasource-admin-plugin.ts` 的 `reregisterPool` 如何判断旧池被保留。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts src/__tests__/datasource-empty-secret-refusal.test.ts`,预期 `Tests 17 passed (17)`(main 上 7 条红:3 条「重启后仍连得上」、4 条空白 secret;对照两边都绿)。 - **需要你做的**:无,席位落地(这是收窄,按规矩先做合同审查)。 ## What changes - **The update stores nothing of a configuration that never came into force.** `updateDatasource` used to seal the new secret, remove the working one, store the merged record, and only then rebuild the pool. It now seals the new secret (the rebuild dereferences the merged record's `credentialsRef`), rebuilds, and only then decides: - the `reregisterPool` seam reports `previousPoolKept: true` → nothing is stored, the `sys_secret` row sealed for this patch is removed, no cluster signal is published (there is nothing for a peer to converge on), and the answer is `toSummary(existing)`: `200`, `status: "error"`, the reason ending "the new configuration is NOT in force"; - otherwise → the merged record is stored, and the replaced secret is retired only **after** that write. A `putDatasourceRecord` that throws now takes the newly sealed secret back out instead of leaving the record pointing at a removed one. - **Which of the two routes, and why:** "never write until the reconnect succeeds", so the record has ONE write path (one `putDatasourceRecord`, after the pool settles). The restore route would have written the record twice on failure, and its second write can fail too. - `active: false` and the no-connectivity-change register keep their order (store, then pool), unchanged. - **The seam reports the outcome.** `DatasourceAdminServiceConfig.reregisterPool` may now answer `{ previousPoolKept: boolean }` (new `ReregisterPoolOutcome` in `datasource-admin-service.ts`, not exported from the index). A seam that answers nothing reads as before: the record is stored whatever the rebuild did. This is the widening half of `Clause-②: yes (narrowing)`. - **The plugin's seam answers it from the state the rebuild left.** `DatasourceAdminServicePlugin`'s `reregisterPool` reads: the new configuration is not `available` AND a driver still answers the name. That driver is the previous pool `reconnect` put back, the only driver a failed rebuild leaves registered (a rebuild with no live pool to keep is a plain `connect`, which registers nothing on failure). It reads the state, not the verdict the rebuild started from: after a first failed PATCH the kept pool serves under a FAILED verdict, and a second failing PATCH must store nothing either (pinned). On a kept pool the plugin's `livePoolRecords` entry goes back to `previous`, so a later cluster convergence compares the shared row against the configuration actually serving. A `reconnect` that throws after restoring the pool is reported as kept (logged at `warn`, as the admin service logged it before); one that throws with nothing kept rethrows. - **A binder that rewrites in place.** The shipped `sys_secret` binder mints a fresh handle per write, so removing the new row restores the binding exactly. For a host binder that reseals under the SAME reference, the working credential is already overwritten: the row is never removed (it is the binding the stored record names) and the loss is logged once at `error` with the remedy. - **Blank secrets.** `assertSecretNotEmpty` now refuses a `secret` whose value is whitespace only, on create and on update, with the same message (`The \`secret\` is empty. … Nothing was written.`) and the same `400 DATASOURCE_ADMIN_ERROR`. No second guard. A value with whitespace around other characters is still sealed as sent. - `datasource-connection-service.ts` is unchanged: `reconnect` already kept the previous pool; the defect was the store write before it. - **Changeset** `.changeset/29879-datasource-update-kept-pool-stores-nothing.md`: `patch` on `@objectstack/service-datasource`, both declaration lines, BREAKING banner, the remedy, ADR-0087 marker `not-required (no-migration-prescription)` (as #28928). **Why `Clause-②: yes (narrowing)` and not the claim's `no`.** The narrowing is the blank-secret refusal, declared as #28928 declared the empty string (`no (narrowing)` there). This diff also widens an exported type (`DatasourceAdminServiceConfig.reregisterPool`'s answer), so the value is `yes`; `yes (narrowing)` is the spelling for a diff that widens one surface and narrows another (`scripts/pm/clause2-line.mjs`). Metadata-protocol stays `none`: `secret` is the doors' credential channel, not an authorable key, so the level is `patch`. ## Tests - **New** `src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts`: the REAL `DatasourceAdminServicePlugin` and `ExternalDatasourceServicePlugin` behind the REAL admin routes, with the REAL `sys_secret` binder, over one in-memory store (`sys_metadata` + `sys_secret`) that outlives a boot. A restart is a second boot over the same store. The pool connects only to database `qa` with the real password. - the card's reproduction: create with the real password → `PATCH {secret: wrong, label}` → `200`, `status: "error"`, "NOT in force" → the stored record and the `sys_secret` ids equal what they were before the PATCH → `/test` ok on the kept pool → restart → `/test` ok, and the restart built its pool with the real password; - a failing `config` change: the same, the stored config is the one serving; - a second failing PATCH after a first one: still nothing stored; - control: a secret that connects is in force and stored, the replaced `sys_secret` row is gone, and the restart connects. - **Extended** `src/__tests__/datasource-empty-secret-refusal.test.ts` (the #28928 harness, real routes over the real service, every seam call recorded): `PATCH` with `" "` and with `{ value: "\t\n" }`, `POST` with `" "` and `"\t\n"` → `400 DATASOURCE_ADMIN_ERROR`, message contains `secret` and "Nothing was written.", no seam call at all. New control: a secret with whitespace around other characters is sealed as sent. <details><summary>Red on main, green on head, two ablations</summary> - **Main** (the two source files restored from `18112bfa`, the tests at head): `Tests 7 failed | 10 passed (17)`. The three kept-pool pins received the card's state, for example `external.credentialsRef` `sys_secret:sec_2` for `sec_1`, `label: "Renamed in the same patch"` stored, `secretIds` `[sec_2]` for `[sec_1]`; the four blank-secret pins received `{ status: 200 | 201, success: true }`. Every control green. Restored: `git diff HEAD` empty. - **Head:** `Tests 17 passed (17)`. - **Ablation 1** (`scripts/ablation-replace.mjs`, `if (previousPoolKept) {` → `if (previousPoolKept && 'ABLATED_29879' === '') {`): `Tests 3 failed | 1 passed (4)`, exactly the three kept-pool pins; the good-secret control green. Restore: blob `cea697a4` equals HEAD's. - **Ablation 2** (`value.trim() !== ''` → `value !== ''`, the #28928 guard): `Tests 4 failed | 9 passed (13)`, exactly the four blank-secret pins. Restore: blob `cea697a4` equals HEAD's. </details> ## Acceptance notes - **The other caller of the seam.** `convergePool` (a peer's rebuild after a cluster signal) ignores the answer, as it should: it writes no record. It benefits from the `livePoolRecords` fix only. - **The pre-#13804 fallback.** A host without the `reregisterPool` seam keeps the incumbent pool through the idempotent register and stores the new record, as before; nothing in that path says "NOT in force". - **A rebuild with no live pool to keep** (the datasource was inactive, or its last connect failed) still stores the new record when it fails: there is no serving configuration to protect, and the answer does not claim one. - **SDK/script callers** that send a whitespace-only `secret` now get the 400; the remedy is in the message and the changeset. ## Verification All readings at head `2e46f163` (four commits on `origin/main` `18112bfa`; main had not moved when the gates and the dogfood ran). <details><summary>Package suite, typecheck, lint</summary> - `pnpm --filter @objectstack/service-datasource test`: `Test Files 71 passed (71)`, `Tests 1169 passed (1169)`, exit 0 (taken before the ledger commit, which touches only `scripts/engine-double-contract.pinned.json`). - `pnpm --filter @objectstack/service-datasource typecheck` (tsc over `src`, tests included): exit 0. - The repo's `pnpm lint` command scoped to `packages/services/service-datasource`: exit 0, no findings. </details> <details><summary>Derived gates (`dispatch-gates.mjs --commands`, 69 families)</summary> - 68 exit 0, `check-adr-0087-registration --base origin/main` and `check-changeset-no-major --base origin/main` included. `check:engine-double-contract` first answered 1 for the new restart rig's doubles (`RETAINED [delete] [findOne] [update]`), then 0 after `--write` and the ledger commit; the 8 families that commit added all exit 0. `check-plugin-teardown-shape --self-test` exit 0 after fetching its pinned fixture commit. - `check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (it needs dists of packages outside this closure). NOT MEASURED locally; CI's. - `dispatch-gates --ran`: `69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN`. - Repo-wide `pnpm lint` and the CI-only families are CI's; see the PR's check-runs. </details> Dogfood battery (owed: `!` title and the `(narrowing)` arm), once at `2e46f163` after `pnpm turbo run build --filter='@objectstack/dogfood...'` (69 tasks, exit 0): `pnpm --filter @objectstack/dogfood test`, `Test Files 415 passed | 3 skipped (418)`, `Tests 3432 passed | 28 skipped (3460)`, lock VERDICT command-exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfDo764sexAMz4Feui)_ <!-- signed: domain:server#6 · os-support-ai · session_01WGufzfDo764sexAMz4Feui --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2be734 commit 5cdaddb

6 files changed

Lines changed: 503 additions & 29 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: a datasource update whose new configuration fails to connect stores none of it, and a whitespace-only `secret` is refused like an empty one
6+
7+
Clause-②: yes (narrowing) — `PATCH /api/v1/datasources/:name` and `POST /api/v1/datasources` refuse one more body that used to answer 200 / 201: a `secret` whose value is whitespace only (`" "`, a tab and a newline), now `400 DATASOURCE_ADMIN_ERROR` with the empty-secret answer. The widening half: the `reregisterPool` member of the exported `DatasourceAdminServiceConfig` may now answer `{ previousPoolKept: boolean }` (still optional; a seam that answers nothing reads as before).
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: secret is the doors' own credential channel, split off the body before the datasource draft is judged. A blank value seals nothing usable, so there is nothing at rest for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the datasource admin doors (`@objectstack/service-datasource`): a whitespace-only `secret` is refused.
13+
14+
- **Before.** A `PATCH` whose new configuration failed to connect (a wrong password, a blank `secret`, a changed `config` that does not reach the database) answered `200` with `status: "error"` and a reason saying the previous pool was kept and the new configuration was NOT in force. It had already stored the new record, repointed `external.credentialsRef` at a new `sys_secret` row and removed the working one, so the next restart connected the configuration that failed. A whitespace-only `secret` passed the empty-secret guard and reached this path.
15+
- **Now.** The update rebuilds the pool before it stores anything. When the previous pool is kept, nothing of the patch is stored: the record, its `credentialsRef` and every other key of the patch stay as they were, and the `sys_secret` row sealed for the patch is removed again. The answer is unchanged (`200`, `status: "error"`, "the new configuration is NOT in force"), and now true: a restart connects the configuration that was serving. A configuration that connects is stored and in force, and the replaced secret is retired, as before. A whitespace-only `secret` is refused before anything is written, by the same guard and with the same message as the empty string; a value with whitespace around other characters is still sealed as sent.
16+
- **Who moves.** The console sends `secret` only when the field holds a value. A script or SDK call that sends a whitespace-only `secret` gets the 400. A host that wires its own `reregisterPool` seam keeps today's behaviour until it answers `{ previousPoolKept: true }` for a rebuild that kept the previous pool.
17+
18+
**The remedy.** To keep the stored credential, leave `secret` out of the request (or send `null`). To replace it, send the new credential.

‎packages/services/service-datasource/src/__tests__/datasource-empty-secret-refusal.test.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import {
3030
* is written, the stored record and its `credentialsRef` are unchanged, and no
3131
* secret is removed. `null`, an absent `secret` and a real one are unchanged.
3232
*
33+
* #29879 — a whitespace-only `secret` (`" "`, a tab and a newline) is blank
34+
* the same way, and the same guard refuses it with the same answer.
35+
*
3336
* Driven through the REAL routes over the REAL service on in-memory stores
3437
* (the harness of `datasource-admin-declared-keys-kept.test.ts`). Every
3538
* secret below is an obvious placeholder.
@@ -135,11 +138,18 @@ async function expectSecretRefusal(res: Response): Promise<string> {
135138
code: 'DATASOURCE_ADMIN_ERROR',
136139
});
137140
expect(body.error!.message).toContain('`secret`');
141+
expect(body.error!.message).toContain('Nothing was written.');
138142
return body.error!.message;
139143
}
140144

141145
describe('PATCH /api/v1/datasources/:name — an empty secret (#28928)', () => {
142-
for (const [label, secret] of [['an empty string', ''], ['an empty `value` object', { value: '' }]] as const) {
146+
for (const [label, secret] of [
147+
['an empty string', ''],
148+
['an empty `value` object', { value: '' }],
149+
// [#29879] Blank is empty: the same guard, the same answer.
150+
['a whitespace-only string', ' '],
151+
['a whitespace-only `value` object', { value: '\t\n' }],
152+
] as const) {
143153
it(`${label} is refused 400 naming \`secret\`; the credential, its ref and the record are unchanged`, async () => {
144154
const { send, records, ops, written } = makeDoor([STORED]);
145155
await expectSecretRefusal(await send('PATCH', '/datasources/wh', { secret }));
@@ -149,6 +159,12 @@ describe('PATCH /api/v1/datasources/:name — an empty secret (#28928)', () => {
149159
});
150160
}
151161

162+
it('control: a secret with whitespace around other characters is a value, sealed as sent (#29879)', async () => {
163+
const { send, written } = makeDoor([STORED]);
164+
expect((await send('PATCH', '/datasources/wh', { secret: ` ${PLACEHOLDER_SECRET}\t` })).status).toBe(200);
165+
expect(written).toEqual([` ${PLACEHOLDER_SECRET}\t`]);
166+
});
167+
152168
it('control: a real secret still rotates (a new ref, the old one removed)', async () => {
153169
const { send, records, ops, written } = makeDoor([STORED]);
154170
expect((await send('PATCH', '/datasources/wh', { secret: PLACEHOLDER_SECRET })).status).toBe(200);
@@ -169,13 +185,15 @@ describe('PATCH /api/v1/datasources/:name — an empty secret (#28928)', () => {
169185
});
170186

171187
describe('POST /api/v1/datasources — an empty secret (#28928)', () => {
172-
it('is refused 400 naming `secret`; nothing is stored and no secret is written', async () => {
173-
const { send, records, ops, written } = makeDoor();
174-
await expectSecretRefusal(await send('POST', '/datasources', { name: 'wh', ...BASE, secret: '' }));
175-
expect(records.has('wh')).toBe(false);
176-
expect(written).toEqual([]);
177-
expect(ops).toEqual([]);
178-
});
188+
for (const secret of ['', ' ', '\t\n']) {
189+
it(`${JSON.stringify(secret)} is refused 400 naming \`secret\`; nothing is stored and no secret is written`, async () => {
190+
const { send, records, ops, written } = makeDoor();
191+
await expectSecretRefusal(await send('POST', '/datasources', { name: 'wh', ...BASE, secret }));
192+
expect(records.has('wh')).toBe(false);
193+
expect(written).toEqual([]);
194+
expect(ops).toEqual([]);
195+
});
196+
}
179197

180198
it('control: a real secret is sealed and bound', async () => {
181199
const { send, records, written } = makeDoor();
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #29879 — a datasource PATCH whose new configuration fails to connect
5+
* answers that the previous pool was kept and the new configuration is NOT in
6+
* force; nothing of the new configuration may then be stored, so the next
7+
* restart connects the configuration that was serving.
8+
*
9+
* The defect: `updateDatasource` stored the merged record, repointed
10+
* `external.credentialsRef` at the newly sealed secret and removed the
11+
* working one BEFORE the rebuild. The rebuild failed and the previous pool
12+
* kept serving, so the answer read "nothing changed", but the restart read
13+
* the failing credential and the datasource could no longer connect, with
14+
* the working credential already gone.
15+
*
16+
* The rig boots the REAL `DatasourceAdminServicePlugin` and
17+
* `ExternalDatasourceServicePlugin` behind the REAL admin routes, with the
18+
* REAL `sys_secret` binder, over one in-memory store (`sys_metadata` and
19+
* `sys_secret`) that outlives a boot. A restart is a second boot over the
20+
* same store: fresh registries, fresh pools, the stored rows only. The
21+
* driver factory's pool connects only with the database's real password,
22+
* the one fact a wrong secret changes.
23+
*/
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
27+
import {
28+
assertEngineDeleteDispatch,
29+
assertEngineFindOnePredicate,
30+
assertEngineUpdateDispatch,
31+
} from '@objectstack/metadata-core';
32+
import type { CryptoContext, CryptoHandle, ICryptoProvider, IDataDriver } from '@objectstack/spec/contracts';
33+
import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js';
34+
import { ExternalDatasourceServicePlugin } from '../plugin.js';
35+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
36+
import { createDatasourceSecretBinder } from '../datasource-secret-binder.js';
37+
import type { IDatasourceDriverFactory, DatasourceConnectionSpec } from '../contracts/datasource-driver-factory.js';
38+
import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js';
39+
40+
type Row = Record<string, unknown>;
41+
42+
/** The database's real password: the only secret its pool connects with. */
43+
const REAL_PASSWORD = 'placeholder-real-password';
44+
const WRONG_PASSWORD = 'placeholder-wrong-password';
45+
const NEW_PASSWORD = 'placeholder-new-password';
46+
47+
/** The store a restart keeps: `sys_metadata` and `sys_secret`, by table. */
48+
function makeStore() {
49+
return { tables: new Map<string, Row[]>(), seq: 0 };
50+
}
51+
type Store = ReturnType<typeof makeStore>;
52+
53+
function rowsOf(store: Store, table: string): Row[] {
54+
if (!store.tables.has(table)) store.tables.set(table, []);
55+
return store.tables.get(table)!;
56+
}
57+
58+
function matches(row: Row, where: Record<string, unknown>): boolean {
59+
for (const [k, v] of Object.entries(where)) {
60+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
61+
if (v === undefined) continue;
62+
if (row[k] !== v) return false;
63+
}
64+
return true;
65+
}
66+
67+
/** AAD-binding crypto double, a fresh handle id per seal (as the shipped provider mints). */
68+
function fakeCrypto(store: Store): ICryptoProvider {
69+
const aadOf = (ctx: CryptoContext) => JSON.stringify([ctx.scope, ctx.namespace, ctx.key]);
70+
return {
71+
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
72+
return {
73+
id: `sec_${++store.seq}`,
74+
kmsKeyId: 'local:test:v1',
75+
alg: 'aes-256-gcm',
76+
version: 1,
77+
ciphertext: Buffer.from(JSON.stringify([aadOf(ctx), plain]), 'utf8').toString('base64'),
78+
};
79+
},
80+
async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
81+
const [aad, plain] = JSON.parse(Buffer.from(handle.ciphertext, 'base64').toString('utf8'));
82+
if (aad !== aadOf(ctx)) throw new Error('AAD mismatch');
83+
return plain;
84+
},
85+
async rotateKey(handle: CryptoHandle): Promise<CryptoHandle> {
86+
return handle;
87+
},
88+
digest: (plain: string) => `sha256:${plain.length}`,
89+
keyedDigest: async (plain: string) => `k:${plain.length}`,
90+
};
91+
}
92+
93+
/** A pool: it connects only to database `qa` with the real password; its health is its connection. */
94+
function passwordFactory(): IDatasourceDriverFactory & { built: string[] } {
95+
const built: string[] = [];
96+
return {
97+
built,
98+
supports: () => true,
99+
create: async (spec: DatasourceConnectionSpec) => {
100+
built.push(spec.secret ?? '');
101+
let connected = false;
102+
const driver = {
103+
name: 'com.fake.postgres',
104+
checkHealth: async () => connected,
105+
disconnect: async () => {
106+
connected = false;
107+
},
108+
};
109+
return {
110+
driver: driver as unknown as IDataDriver,
111+
connect: async () => {
112+
if ((spec.config as { database?: unknown })?.database !== 'qa') throw new Error('database does not exist');
113+
if (spec.secret !== REAL_PASSWORD) throw new Error('password authentication failed');
114+
connected = true;
115+
},
116+
};
117+
},
118+
};
119+
}
120+
121+
/** One boot over `store`: the real plugins, the real routes, a driver registry of its own. */
122+
async function boot(store: Store) {
123+
const drivers = new Map<string, IDataDriver>();
124+
const data = {
125+
findOne: async (table: string, q: { where: Record<string, unknown> }) => {
126+
assertEngineFindOnePredicate(table, q);
127+
return rowsOf(store, table).find((r) => matches(r, q.where)) ?? null;
128+
},
129+
find: async (table: string, q?: { where?: Record<string, unknown>; limit?: number }) => {
130+
const all = rowsOf(store, table).filter((r) => matches(r, q?.where ?? {}));
131+
return typeof q?.limit === 'number' ? all.slice(0, q.limit) : all;
132+
},
133+
insert: async (table: string, row: Row) => {
134+
const stored = { ...row, id: row.id ?? `r_${++store.seq}` };
135+
rowsOf(store, table).push(stored);
136+
return { id: stored.id };
137+
},
138+
update: async (table: string, row: Row, q: { where: Record<string, unknown> }) => {
139+
assertEngineUpdateDispatch(row, q);
140+
for (const r of rowsOf(store, table)) if (matches(r, q.where)) Object.assign(r, row);
141+
return {};
142+
},
143+
delete: async (table: string, q: { where: Record<string, unknown> }) => {
144+
assertEngineDeleteDispatch(q);
145+
const rows = rowsOf(store, table);
146+
const before = rows.length;
147+
for (let i = rows.length - 1; i >= 0; i--) if (matches(rows[i], q.where)) rows.splice(i, 1);
148+
return { deleted: before - rows.length };
149+
},
150+
registerDriver: (driver: IDataDriver) => {
151+
if (!drivers.has(driver.name)) drivers.set(driver.name, driver);
152+
},
153+
unregisterDriver: (name: string) => drivers.delete(name),
154+
getDriverByName: (name: string) => drivers.get(name),
155+
registerDatasourceDef: () => {},
156+
markDatasourceUnavailable: () => {},
157+
clearDatasourceUnavailable: () => {},
158+
};
159+
160+
const registry = new Map<string, Map<string, unknown>>();
161+
const metadata = {
162+
get: async (t: string, n: string) => registry.get(t)?.get(n),
163+
list: async (t: string) => [...(registry.get(t)?.values() ?? [])],
164+
register: async (t: string, n: string, d: unknown) => {
165+
if (!registry.has(t)) registry.set(t, new Map());
166+
registry.get(t)!.set(n, d);
167+
},
168+
unregister: async (t: string, n: string) => {
169+
registry.get(t)?.delete(n);
170+
},
171+
listObjects: async () => [],
172+
};
173+
174+
const services = new Map<string, unknown>([
175+
['data', data],
176+
['metadata', metadata],
177+
['auth', createSessionAuthService()],
178+
// The routes resolve the caller's grants from `objectql` first.
179+
['objectql', createGrantsEngine()],
180+
]);
181+
const logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() };
182+
const ctx: any = {
183+
getService: (name: string) => {
184+
if (services.has(name)) return services.get(name);
185+
throw new Error(`no service ${name}`);
186+
},
187+
registerService: (name: string, svc: unknown) => {
188+
services.set(name, svc);
189+
},
190+
trigger: async () => {},
191+
logger,
192+
};
193+
194+
const secrets = createDatasourceSecretBinder({ engine: data as never, cryptoProvider: fakeCrypto(store) });
195+
const factory = passwordFactory();
196+
const admin = new DatasourceAdminServicePlugin({ driverFactory: factory, secrets, logger });
197+
const external = new ExternalDatasourceServicePlugin();
198+
await admin.init(ctx);
199+
await external.init(ctx);
200+
await admin.start(ctx);
201+
await external.start(ctx);
202+
203+
const server = new HonoHttpServer(0);
204+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
205+
const app = server.getRawApp();
206+
const send = async (method: string, path: string, body?: unknown) => {
207+
const res = await app.fetch(
208+
new Request(`http://local/api/v1${path}`, {
209+
method,
210+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
211+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
212+
}),
213+
);
214+
return { status: res.status, body: (await res.json()) as any };
215+
};
216+
return { send, factory, logger };
217+
}
218+
219+
const CREATE = {
220+
name: 'r7pg3',
221+
driver: 'postgres',
222+
schemaMode: 'external',
223+
external: {},
224+
config: { host: '127.0.0.1', port: 5432, database: 'qa', username: 'qa' },
225+
};
226+
227+
/** The stored datasource record and the `sys_secret` ids, as a restart will read them. */
228+
function atRest(store: Store) {
229+
const row = rowsOf(store, 'sys_metadata').find((r) => r.type === 'datasource' && r.name === CREATE.name);
230+
return {
231+
record: row ? JSON.parse(String(row.metadata)) : undefined,
232+
secretIds: rowsOf(store, 'sys_secret').map((r) => r.id).sort(),
233+
};
234+
}
235+
236+
async function created(store: Store) {
237+
const first = await boot(store);
238+
const res = await first.send('POST', '/datasources', { ...CREATE, secret: REAL_PASSWORD });
239+
expect({ status: res.status, dsStatus: res.body.data?.datasource?.status }).toEqual({ status: 201, dsStatus: 'ok' });
240+
return first;
241+
}
242+
243+
describe('PATCH /api/v1/datasources/:name — a new secret that fails to connect (#29879)', () => {
244+
it('answers NOT in force, stores nothing of it, and the restart still connects', async () => {
245+
const store = makeStore();
246+
const first = await created(store);
247+
const before = atRest(store);
248+
expect(before.record.external.credentialsRef).toMatch(/^sys_secret:/);
249+
expect(before.secretIds).toHaveLength(1);
250+
251+
const res = await first.send('PATCH', `/datasources/${CREATE.name}`, {
252+
secret: WRONG_PASSWORD,
253+
label: 'Renamed in the same patch',
254+
});
255+
expect(res.status).toBe(200);
256+
expect(res.body.data.datasource.status).toBe('error');
257+
expect(res.body.data.datasource.statusReason).toContain('the new configuration is NOT in force');
258+
259+
// Nothing of the patch is at rest: not the new binding, not the other key,
260+
// and the secret sealed for it is gone again.
261+
expect(atRest(store)).toEqual(before);
262+
// The kept pool still serves.
263+
expect((await first.send('POST', `/datasources/${CREATE.name}/test`)).body.data).toMatchObject({ ok: true });
264+
265+
const restarted = await boot(store);
266+
expect((await restarted.send('POST', `/datasources/${CREATE.name}/test`)).body.data).toMatchObject({ ok: true });
267+
expect(restarted.factory.built).toEqual([REAL_PASSWORD]);
268+
});
269+
270+
it('the same for a failing config change: the stored config is the one serving', async () => {
271+
const store = makeStore();
272+
const first = await created(store);
273+
const before = atRest(store);
274+
275+
const res = await first.send('PATCH', `/datasources/${CREATE.name}`, {
276+
config: { ...CREATE.config, database: 'qa_other' },
277+
});
278+
expect(res.body.data.datasource.statusReason).toContain('the new configuration is NOT in force');
279+
expect(atRest(store)).toEqual(before);
280+
expect((await boot(store)).factory.built).toEqual([REAL_PASSWORD]);
281+
});
282+
283+
it('control: a secret that connects is in force and stored, and the one it replaces is retired', async () => {
284+
const store = makeStore();
285+
const first = await created(store);
286+
const before = atRest(store);
287+
288+
const res = await first.send('PATCH', `/datasources/${CREATE.name}`, { secret: REAL_PASSWORD });
289+
expect(res.status).toBe(200);
290+
expect(res.body.data.datasource.status).toBe('ok');
291+
292+
const after = atRest(store);
293+
expect(after.record.external.credentialsRef).not.toBe(before.record.external.credentialsRef);
294+
expect(after.secretIds).toHaveLength(1);
295+
expect(after.secretIds).not.toEqual(before.secretIds);
296+
297+
const restarted = await boot(store);
298+
expect((await restarted.send('POST', `/datasources/${CREATE.name}/test`)).body.data).toMatchObject({ ok: true });
299+
});
300+
301+
it('a second failing PATCH after a first stores nothing either', async () => {
302+
// The first leaves the previous pool serving under a FAILED verdict, so
303+
// "kept" cannot be read off the verdict the rebuild started from.
304+
const store = makeStore();
305+
const first = await created(store);
306+
const before = atRest(store);
307+
for (const secret of [WRONG_PASSWORD, NEW_PASSWORD]) {
308+
const res = await first.send('PATCH', `/datasources/${CREATE.name}`, { secret });
309+
expect(res.body.data.datasource.statusReason).toContain('the new configuration is NOT in force');
310+
}
311+
expect(atRest(store)).toEqual(before);
312+
expect((await boot(store)).factory.built).toEqual([REAL_PASSWORD]);
313+
});
314+
});

0 commit comments

Comments
 (0)