Skip to content

Commit c6954d6

Browse files
fix(service-datasource): the datasource admin door judges the record it will persist against DatasourceSchema (#21058) (#21133)
Fixes #21058 Clause-②: no ## What this changes `POST /api/v1/datasources`, `PATCH /api/v1/datasources/:name` and `POST /api/v1/datasources/test` now judge the datasource record against `DatasourceSchema` as it will be persisted, before any secret or record write. `os build` and `PUT /api/v1/meta/datasource/:name` already enforce that contract. - **The binding is included.** The judged record carries the binding it will have: the existing `external.credentialsRef`, or the one a supplied `secret` is about to write. The latter is a stand-in ref, because the real one does not exist until `writeSecret` runs. - **The envelope is reused.** A refusal answers with the door's existing `400 DATASOURCE_ADMIN_ERROR`, the one an inline url password already gets (card step 5), and carries `DatasourceSchema`'s own message. - **Nothing new is added.** There is no new error code, no new spec export, and no edit under `packages/spec`. The change lands in one file, `packages/services/service-datasource/src/datasource-admin-service.ts`. It adds one private `recordRefusal` (a `DatasourceSchema.safeParse` of the record, with each issue mapped to `path: message`) and three call sites. No refinement is restated in `service-datasource`. ## Measured decisions 1. **The premise holds: no spec export is needed.** The record to be persisted parses as a `Datasource`, and the refinements fire. Readings at `53ed3d10`: - card step 2 gives one issue at `config.url` (`CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED`); - card step 4 gives one issue at `config.username` (its composed twin); - `mongodb://app@…` with a bound ref is valid; - `schemaMode: 'external'` with no `external` block gives one issue at `external`. 2. **The whole schema is judged, not a picked subset of refinements.** zod runs a refinement only once the record parses structurally. Measured: a seeded record with no `config` reports the `config` issue alone, and the managed-federation refinement on the same record never runs. So every issue the schema raises is a refusal. Two more inputs are now refused, and the build and meta doors already refuse both: - a create with no `config`; - a `pool` key the schema does not declare. The changeset lists both. 3. **Update: a write that changes only `label` and/or `active` is not judged.** The existing config and pool gates already make this exemption, so a stored row can always be renamed or taken out of service. Any other patched key (`driver`, `schemaMode`, `config`, `external`, `pool`), or a supplied `secret`, judges the whole merged record. The judgement runs before the redacted-credential restore, on the same subject the config gate reads: what the author wrote. The restore cannot move a cross-field verdict, because `redactUrlPassword` keeps the username in the userinfo. 4. **`testConnection` shares the gap, and this PR closes it too.** Measured in `default-datasource-driver-factory.ts`: - `buildMongoAuth` returns `undefined` for a url whose userinfo names no user; - `buildMongoUrl` writes no userinfo when `username` is absent. So a probe that carries a secret connects anonymously and reports green for a pairing that Save now refuses. The test door already guards against that shape for `config`. It now answers `ok: false` with the same message and does not probe. The console's test call (objectui pin `e420df31`, `DatasourceResourcePage.tsx:369`) sends `{ driver, config, secret }` with no `name`, so the judged copy takes a fixed stand-in name. The probe's subject is the connection, and the test door never judged identity. ## Pins The pins live in `src/__tests__/datasource-admin-record-judgement.test.ts` and drive the real routes over the real service. Each refusal asserts four things: - status 400; - `error.code` `DATASOURCE_ADMIN_ERROR`; - a message containing the issue `DatasourceSchema` itself raises for the same record (derived in the test, never restated); - the store behind it: nothing persisted, no secret minted or unbound. Cases by door: - **Create.** - Refusals: card step 2, card step 4, and `schemaMode: 'external'` with no block. - Controls: a url that names a user plus a secret gives 201 and is bound; a user-less url with no secret gives 201; external mode with `external: {}` gives 201. - **Update.** - Refusals: dropping the url user under an existing binding (the record is unchanged); a secret for a user-less url (no secret is minted); a composed config with no username plus a secret; switching to external mode with no block. - Control: a url that names a user plus a secret gives 200. - Exemption: `active: false` and `label` on an already-stored invalid row give 200. - **Test door.** - Refusal: a user-less url plus a secret gives `ok: false`, and the probe is never called. - Control: the console-shaped draft (no name) is probed. Two seeds in `datasource-admin-service.test.ts` were never spec-valid. One had no `config`; the other carried a federation key on a managed row. Both are now judged, so both gain the missing declarations. Their pins (secret rewrap, ref preservation) are unchanged. ### Ablations Each leg went through `scripts/ablation-replace.mjs` at `c9c28fcf`: the anchor hit once and dropped from 1 to 0, and every restore was proven by the HEAD-blob hash `f4bb912e`. | leg | mutation | what went red | |---|---|---| | A1 | drop the create judgement | 3 create refusals: `expected 201 to be 400` | | A2 | drop the update judgement | 4 update refusals | | A3 | ignore the test-door refusal | the test-door refusal: `expected true to be false` | | A4b | write the secret before the create judgement | the step 2 and step 4 pins: `expected [ 'writeSecret' ] to deeply equal []` and `expected 1 to be +0`. Status stays 400, so the ordering assertions are what catch it. | | A5 | judge every update (drop the exemption) | the exemption pin: `expected 400 to be 200` | | A6 | judge the test draft's own name | the console-shaped control (no name) | The first A4 attempt was a no-op. Its replacement contained the anchor, so the tool refused it (anchor count stayed at 1) and ran no test. A4b re-ran it with a replacement that does not contain the anchor. ## Live repro The card's steps were run on a fresh showcase boot at `38696df1` (`objectstack dev --seed-admin`, my own port, torn down afterwards). | request | card (before) | this branch | |---|---|---| | step 2: POST, user-less url + secret | 201 | 400 `DATASOURCE_ADMIN_ERROR`, `config.url: this mongo …names no user…`; GET then gives 404 | | step 4: POST, composed config + secret | 201 | 400 `config.username: …`; GET then gives 404 | | POST, external mode with no block | 201 | 400 `external: schemaMode='external' requires 'external' settings.` | | control: POST, `mongodb://app@…` + secret | (not run) | 201; `hasSecret: true`; meta `_diagnostics.valid: true` | | PATCH dropping the url user, on the really-stored record | (not run) | 400; the record is unchanged | | PATCH, console-shaped round trip | (not run) | 200 | | PATCH `active: false` | (not run) | 200 | | test door: user-less url + secret | (not run) | `ok: false`; no probe | | `PUT /meta`: the same pairing | 422 | 422 (unchanged) | | step 5: inline url password | 400 | 400 (unchanged) | ## Verification All readings are at `38696df1` unless noted. - **Typecheck.** `pnpm --filter @objectstack/service-datasource typecheck` exits 0, and `tsc --listFiles` includes the new test file. - **Tests.** `pnpm --filter @objectstack/service-datasource test`: 35 files, 707 tests passed. - **Builds.** The dependency closure builds with exit 0. The package build passes `check-dts-emitted` (4 of 4 declaration files). - **Gate derivation.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` over 4 paths against merge base `53ed3d10` gives 62 commands. Reconciled with `--ran`: 60 ran with exit 0, and 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET; each needs the whole workspace built): - `check:dual-build-cjs-loads`: in its place, this package's own `dist/index.cjs` and `dist/index.js` were loaded, and both export `DatasourceAdminService`; - `check:type-check-debt`. - **Shallow clone.** `check-plugin-teardown-shape --self-test` first exited 3, because its fixture commit `621a4876` was unreachable. After fetching that one commit, the re-run exited 0. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 3 changed `.ts` files reports 3 files, 0 errors and 0 warnings. All 3 are in the lint population (`--print-config` resolves each). `eslint.config.mjs` never enables type-aware linting, so this diff cannot change the verdict on an untouched file. The full `pnpm lint` run belongs to CI. ## Acceptance notes - **objectui producer, measured at pin `e420df31`.** - The console's datasource editor offers `schemaMode` `external` and `validate-only` (`DatasourceResourcePage.tsx:166-167`), but its `draftBody` never sends an `external` block (`:353-361`). - Before this PR, a Save in those modes answered 201 and read back `valid: false`. - After this PR, such a Save **without** a secret answers 400 `external: schemaMode='external' requires 'external' settings.`, and the console has no field the user can act on. With a secret, the stored `external.credentialsRef` supplies the block and the Save passes. - Producer fix: send `external: {}` (or the federation settings) whenever `schemaMode` is not `managed`. This is reported to the PM for filing and not changed here. - **Secret-only PATCH on an older row.** A `PATCH` that carries only a `secret` judges the whole stored record. A row stored before the inline-credential refusal, still holding `config.password`, is refused on such a PATCH, and the message names that key. The console always sends `config` (redacted) alongside a secret, so the console path is not affected. - **Body keys `toRecord` drops (read, not exercised).** The admin door's `toRecord` copies only the `DatasourceDraft` keys, so a body's `ssl`, `description` or `autoConnect` is still dropped without a word. This is outside this card; it is noted only. - **Neighbours.** #21086 and #21087 had not landed when this PR opened, and nothing on `main` since `53ed3d10` touches these files. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 432c8ab commit c6954d6

4 files changed

Lines changed: 492 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): `POST` / `PATCH /api/v1/datasources` and `POST /api/v1/datasources/test` judge the datasource record they will persist against `DatasourceSchema`, the contract `os build` and `PUT /api/v1/meta/datasource/:name` already enforce (#21058)
6+
7+
Clause-②: no
8+
9+
- Before, these doors checked the driver `config` alone. A record that `DatasourceSchema` refuses was accepted `201`, and `GET /api/v1/meta/datasource/:name` then reported it `valid: false`. The record is now judged as it will be stored, with the `external.credentialsRef` that a supplied `secret` (or the existing binding) carries, before any secret or record is written. A refusal answers `400 DATASOURCE_ADMIN_ERROR` with `DatasourceSchema`'s own message, and nothing is persisted.
10+
- Newly refused, for example: a mongo `config.url` whose userinfo names no user, or a composed mongo `config` with no `username`, beside a `secret`. The bound secret was never used and the datasource connected anonymously. Fix: put the user in the url (`mongodb://user@host/db`) or in `config.username`, or send no `secret`. Also refused: `schemaMode: 'external'` or `'validate-only'` with no `external` block. Fix: send `external: {}` or the federation settings. Also refused: a create with no `config`, or a `pool` key the schema does not declare.
11+
- `POST /api/v1/datasources/test` answers `ok: false` with the same message instead of probing, so a green test no longer comes before a refused save.
12+
- A `PATCH` that changes only `label` and/or `active` is not judged. A row stored before this change can still be renamed or taken out of service.
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { DatasourceSchema } from '@objectstack/spec/data';
5+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
6+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
7+
import {
8+
DatasourceAdminService,
9+
type DatasourceAdminServiceConfig,
10+
type StoredDatasource,
11+
type ProbeInput,
12+
} from '../datasource-admin-service.js';
13+
import {
14+
ENTITLED_CREDENTIAL,
15+
createSessionAuthService,
16+
createGrantsEngine,
17+
} from './entitled-caller.fixture.js';
18+
19+
/**
20+
* #21058 — the datasource admin door judges the record it will persist against
21+
* `DatasourceSchema`, the contract `os build` and `PUT /api/v1/meta/datasource`
22+
* already enforce.
23+
*
24+
* Before, the door ran the driver's `config` contract alone, so every
25+
* refinement reading two fields at once was skipped: a mongo `config.url` that
26+
* names no user (or a composed config with no `username`) beside the
27+
* `credentialsRef` a supplied secret binds was accepted 201 — the factory never
28+
* reads that secret, so the datasource connected anonymously — and
29+
* `schemaMode: 'external'` with no `external` block was accepted too. The meta
30+
* read path then reported each stored row `valid: false`.
31+
*
32+
* Driven through the REAL routes over the REAL service, so each pin reads the
33+
* HTTP envelope a caller sees (status + `error.code`) and the store behind it
34+
* (nothing written, no secret minted or unbound). The refusal message is the
35+
* one `DatasourceSchema` itself produces for the same record — derived here
36+
* from the schema, never restated.
37+
*/
38+
39+
const authService = createSessionAuthService();
40+
const grantsEngine = createGrantsEngine();
41+
42+
/** In-memory store + secret store behind a real {@link DatasourceAdminService}. */
43+
function makeDoor(seed: StoredDatasource[] = []) {
44+
const records = new Map<string, StoredDatasource>(seed.map((r) => [r.name, structuredClone(r)]));
45+
const secrets = new Map<string, string>();
46+
let seq = 0;
47+
const ops: string[] = [];
48+
const probed: ProbeInput[] = [];
49+
50+
const config: DatasourceAdminServiceConfig = {
51+
probe: async (input) => {
52+
probed.push(input);
53+
return { ok: true, latencyMs: 1 };
54+
},
55+
listDatasourceRecords: async () => [...records.values()].map((r) => structuredClone(r)),
56+
getDatasourceRecord: async (name) => {
57+
const r = records.get(name);
58+
return r ? structuredClone(r) : undefined;
59+
},
60+
putDatasourceRecord: async (record) => {
61+
ops.push(`put:${record.name}`);
62+
records.set(record.name, structuredClone(record));
63+
},
64+
deleteDatasourceRecord: async (name) => {
65+
records.delete(name);
66+
},
67+
writeSecret: async (input, hint) => {
68+
ops.push('writeSecret');
69+
const ref = `sys_secret://datasource/${hint.name}#${++seq}`;
70+
secrets.set(ref, input.value);
71+
return ref;
72+
},
73+
removeSecret: async (ref) => {
74+
ops.push('removeSecret');
75+
secrets.delete(ref);
76+
},
77+
countBoundObjects: async () => 0,
78+
registerPool: () => {
79+
ops.push('registerPool');
80+
},
81+
};
82+
83+
const service = new DatasourceAdminService(config);
84+
const server = new HonoHttpServer(0);
85+
const ctx = {
86+
getService: vi.fn((name: string) =>
87+
name === 'auth'
88+
? authService
89+
: name === 'objectql' || name === 'data'
90+
? grantsEngine
91+
: name === 'datasource-admin'
92+
? service
93+
: undefined,
94+
),
95+
} as any;
96+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
97+
const app = server.getRawApp();
98+
99+
const send = (method: string, path: string, body: unknown) =>
100+
app.fetch(
101+
new Request(`http://local/api/v1${path}`, {
102+
method,
103+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
104+
body: JSON.stringify(body),
105+
}),
106+
);
107+
108+
return { send, records, secrets, ops, probed };
109+
}
110+
111+
/**
112+
* `<path>: <message>` exactly as `DatasourceSchema` words the issue at `path`
113+
* for `record` — the build door's own message. Throws when the schema raises
114+
* no issue there, so a pin can never pass on a premise the spec no longer
115+
* holds.
116+
*/
117+
function schemaIssue(record: Record<string, unknown>, path: string): string {
118+
const result = DatasourceSchema.safeParse(record);
119+
const issue = result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === path);
120+
if (!issue) throw new Error(`fixture premise broken: DatasourceSchema raises no issue at '${path}'`);
121+
return `${path}: ${issue.message}`;
122+
}
123+
124+
/** The binding a supplied secret creates, as the schema sees it (any non-empty ref). */
125+
const BOUND = { credentialsRef: 'sys_secret://datasource/any#1' };
126+
127+
const USERLESS_URL = 'mongodb://127.0.0.1:27017/qa';
128+
const USER_URL = 'mongodb://app@127.0.0.1:27017/qa';
129+
130+
async function expectRefusal(res: Response, expected: string): Promise<void> {
131+
expect(res.status).toBe(400);
132+
const body = (await res.json()) as { success: boolean; error: { code: string; message: string } };
133+
expect(body.success).toBe(false);
134+
expect(body.error.code).toBe('DATASOURCE_ADMIN_ERROR');
135+
expect(body.error.message).toContain(expected);
136+
}
137+
138+
describe('POST /api/v1/datasources judges the record it will persist (#21058)', () => {
139+
it('refuses a mongo url that names no user beside a supplied secret — nothing persisted, no secret written', async () => {
140+
const door = makeDoor();
141+
const res = await door.send('POST', '/datasources', {
142+
name: 'r19b_mg_nouser',
143+
driver: 'mongodb',
144+
config: { url: USERLESS_URL },
145+
secret: 'any-secret',
146+
});
147+
148+
await expectRefusal(
149+
res,
150+
schemaIssue({ name: 'r19b_mg_nouser', driver: 'mongodb', config: { url: USERLESS_URL }, external: BOUND }, 'config.url'),
151+
);
152+
expect(door.records.size).toBe(0);
153+
expect(door.secrets.size).toBe(0);
154+
expect(door.ops).toEqual([]);
155+
});
156+
157+
it('refuses a composed mongo config with no username beside a supplied secret', async () => {
158+
const door = makeDoor();
159+
const config = { host: '127.0.0.1', database: 'qa' };
160+
const res = await door.send('POST', '/datasources', {
161+
name: 'r19b_mg_composed',
162+
driver: 'mongodb',
163+
config,
164+
secret: 'any',
165+
});
166+
167+
await expectRefusal(
168+
res,
169+
schemaIssue({ name: 'r19b_mg_composed', driver: 'mongodb', config, external: BOUND }, 'config.username'),
170+
);
171+
expect(door.records.size).toBe(0);
172+
expect(door.ops).toEqual([]);
173+
});
174+
175+
it('refuses schemaMode external with no external block', async () => {
176+
const door = makeDoor();
177+
const config = { filename: ':memory:' };
178+
const res = await door.send('POST', '/datasources', {
179+
name: 'ext_no_block',
180+
driver: 'sqlite',
181+
schemaMode: 'external',
182+
config,
183+
});
184+
185+
await expectRefusal(
186+
res,
187+
schemaIssue({ name: 'ext_no_block', driver: 'sqlite', schemaMode: 'external', config }, 'external'),
188+
);
189+
expect(door.records.size).toBe(0);
190+
expect(door.ops).toEqual([]);
191+
});
192+
193+
it('control: a url that names a user, with a secret, is created and bound', async () => {
194+
const door = makeDoor();
195+
const res = await door.send('POST', '/datasources', {
196+
name: 'mg_app',
197+
driver: 'mongodb',
198+
config: { url: USER_URL },
199+
secret: 'any-secret',
200+
});
201+
202+
expect(res.status).toBe(201);
203+
expect(door.records.get('mg_app')?.external?.credentialsRef).toMatch(/^sys_secret:/);
204+
expect(door.secrets.size).toBe(1);
205+
});
206+
207+
it('control: a user-less url with NO secret is an unbound, anonymous datasource and is created', async () => {
208+
const door = makeDoor();
209+
const res = await door.send('POST', '/datasources', {
210+
name: 'mg_anon',
211+
driver: 'mongodb',
212+
config: { url: USERLESS_URL },
213+
});
214+
215+
expect(res.status).toBe(201);
216+
expect(door.records.get('mg_anon')?.external).toBeUndefined();
217+
});
218+
219+
it('control: schemaMode external WITH an external block is created', async () => {
220+
const door = makeDoor();
221+
const res = await door.send('POST', '/datasources', {
222+
name: 'ext_with_block',
223+
driver: 'sqlite',
224+
schemaMode: 'external',
225+
config: { filename: ':memory:' },
226+
external: {},
227+
});
228+
229+
expect(res.status).toBe(201);
230+
});
231+
});
232+
233+
describe('PATCH /api/v1/datasources/:name judges the merged record (#21058)', () => {
234+
const boundRow = (): StoredDatasource => ({
235+
name: 'mg',
236+
driver: 'mongodb',
237+
origin: 'runtime',
238+
config: { url: USER_URL },
239+
external: { credentialsRef: 'sys_secret://datasource/mg#0' },
240+
});
241+
242+
it('refuses a config that drops the url user while the existing binding stays — record and secret untouched', async () => {
243+
const door = makeDoor([boundRow()]);
244+
const before = structuredClone(door.records.get('mg'));
245+
246+
const res = await door.send('PATCH', '/datasources/mg', { config: { url: USERLESS_URL } });
247+
248+
await expectRefusal(
249+
res,
250+
schemaIssue({ name: 'mg', driver: 'mongodb', config: { url: USERLESS_URL }, external: BOUND }, 'config.url'),
251+
);
252+
expect(door.records.get('mg')).toEqual(before);
253+
expect(door.ops).toEqual([]);
254+
});
255+
256+
it('refuses a secret supplied for a user-less url — no secret minted', async () => {
257+
const door = makeDoor([{ name: 'mg', driver: 'mongodb', origin: 'runtime', config: { url: USERLESS_URL } }]);
258+
259+
const res = await door.send('PATCH', '/datasources/mg', { secret: 'any-secret' });
260+
261+
await expectRefusal(
262+
res,
263+
schemaIssue({ name: 'mg', driver: 'mongodb', config: { url: USERLESS_URL }, external: BOUND }, 'config.url'),
264+
);
265+
expect(door.records.get('mg')?.external).toBeUndefined();
266+
expect(door.ops).toEqual([]);
267+
});
268+
269+
it('refuses a composed config with no username beside a supplied secret', async () => {
270+
const door = makeDoor([
271+
{ name: 'mg', driver: 'mongodb', origin: 'runtime', config: { host: '127.0.0.1', database: 'qa', username: 'app' } },
272+
]);
273+
const config = { host: '127.0.0.1', database: 'qa' };
274+
275+
const res = await door.send('PATCH', '/datasources/mg', { config, secret: 'any' });
276+
277+
await expectRefusal(res, schemaIssue({ name: 'mg', driver: 'mongodb', config, external: BOUND }, 'config.username'));
278+
expect(door.ops).toEqual([]);
279+
});
280+
281+
it('refuses switching to schemaMode external with no external block', async () => {
282+
const door = makeDoor([{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: ':memory:' } }]);
283+
284+
const res = await door.send('PATCH', '/datasources/lite', { schemaMode: 'external' });
285+
286+
await expectRefusal(
287+
res,
288+
schemaIssue({ name: 'lite', driver: 'sqlite', schemaMode: 'external', config: { filename: ':memory:' } }, 'external'),
289+
);
290+
expect(door.records.get('lite')?.schemaMode).toBeUndefined();
291+
});
292+
293+
it('control: a url that names a user, with a secret, is accepted and bound', async () => {
294+
const door = makeDoor([{ name: 'mg', driver: 'mongodb', origin: 'runtime', config: { url: USERLESS_URL } }]);
295+
296+
const res = await door.send('PATCH', '/datasources/mg', { config: { url: USER_URL }, secret: 'any-secret' });
297+
298+
expect(res.status).toBe(200);
299+
expect(door.records.get('mg')?.external?.credentialsRef).toMatch(/^sys_secret:/);
300+
});
301+
302+
// The exemption: a row stored with the pair before this door judged it must
303+
// still be renamable and — the point — takeable out of service.
304+
it('keeps a label/active-only edit of an already-stored invalid row writable', async () => {
305+
const door = makeDoor([
306+
{ ...boundRow(), config: { url: USERLESS_URL } },
307+
]);
308+
309+
const off = await door.send('PATCH', '/datasources/mg', { active: false });
310+
expect(off.status).toBe(200);
311+
expect(door.records.get('mg')?.active).toBe(false);
312+
313+
const renamed = await door.send('PATCH', '/datasources/mg', { label: 'Legacy' });
314+
expect(renamed.status).toBe(200);
315+
expect(door.records.get('mg')?.label).toBe('Legacy');
316+
});
317+
});
318+
319+
describe('POST /api/v1/datasources/test judges the draft a Save would persist (#21058)', () => {
320+
it('refuses to probe a user-less url beside a secret the probe would never use', async () => {
321+
const door = makeDoor();
322+
const res = await door.send('POST', '/datasources/test', {
323+
driver: 'mongodb',
324+
config: { url: USERLESS_URL },
325+
secret: 'any-secret',
326+
});
327+
328+
expect(res.status).toBe(200);
329+
const { data } = (await res.json()) as { data: { result: { ok: boolean; error?: string } } };
330+
expect(data.result.ok).toBe(false);
331+
// Built from the same record the create door judges; the test door's own
332+
// identity stand-in is irrelevant to the issue at `config.url`.
333+
expect(data.result.error).toContain(
334+
schemaIssue({ name: 'probe', driver: 'mongodb', config: { url: USERLESS_URL }, external: BOUND }, 'config.url'),
335+
);
336+
expect(door.probed).toHaveLength(0);
337+
});
338+
339+
it('control: the console-shaped draft (no name) with a url that names a user is probed', async () => {
340+
const door = makeDoor();
341+
const res = await door.send('POST', '/datasources/test', {
342+
driver: 'mongodb',
343+
config: { url: USER_URL },
344+
secret: 'any-secret',
345+
});
346+
347+
expect(res.status).toBe(200);
348+
const { data } = (await res.json()) as { data: { result: { ok: boolean } } };
349+
expect(data.result.ok).toBe(true);
350+
expect(door.probed).toHaveLength(1);
351+
expect(door.probed[0].secret).toBe('any-secret');
352+
});
353+
});

0 commit comments

Comments
 (0)