Skip to content

Commit cfd237f

Browse files
feat(spec)!: a URL is judged as a URL parser cleans it — surrounding whitespace trimmed, tab and newline characters removed — with one userinfo grammar at both doors; a credential query parameter is refused in any string of a connector entry (#29875)
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no connector entry that `skills/**`, the hand-written `content/docs/**`, `examples/**` or `packages/apps/**` author holds a credential query parameter in any string under `providerConfig` or `auth`, and none of their datasource or connector URLs carries a credential part (scanned: 0 connector or datasource literals affected; the shipped OpenAPI documents those apps reference by path: 0), so nothing they teach changes; each refusal carries its own prescription. ## 维护者速读 - 改了什么:连接器实例与数据源驱动配置里的值按 URL 解析器的读法判定:先去掉首尾的 C0 控制字符与空格,并删除值中任意位置的制表符、换行符与回车符,再判定——userinfo 与查询参数两半、连接器与数据源两扇门都如此;连接器的 userinfo 改用数据源已有的同一套语法(权限部分以 `scheme://` 或无 scheme 的 `//` 起始);连接器条目 `providerConfig` 与 `auth` 下任意字符串里带值的凭据类查询参数一律拒绝。保存门 422 `INVALID_METADATA`,定位到该字符串;两侧读取在写入判定的原始位置改写,其余字符原样返回。 - 为什么改:维护者裁决 D:两扇门一条值级规则,目的是让检查看到的就是使用方解析器看到的。删除制表/换行字符与连接器沿用数据源的 scheme 可选语法,正是这同一条规则;二者超出裁决的字面文本,席位已作为否决窗口呈报维护者(经契约复核档审核为既裁执行)。 - 风险与回滚:破坏性收窄(spec minor,一条 ADR-0087 D3 条目 `url-credential-judged-trimmed-and-in-any-connector-string`)。升级后在校验、构建产物加载与保存时被拒的有:连接器 `providerConfig`/`auth` 的任意字符串里带值的凭据查询参数;按解析器清洗后(去首尾空白、删除制表/换行字符)含凭据部分的 URL;连接器中无 scheme 的 `//` 权限部分所带的 userinfo。仓内 examples/apps/skills/docs 实测 0 处受影响。已存行不重判;读取隐去凭据部分,原样保存保留库值。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/driver/url-boundary.ts`(唯一的边界解析:按解析器清洗并保留到原始串的偏移映射,userinfo 区间供两扇门共用)、`packages/spec/src/integration/connector-url-credentials.ts`(`connectorUrlWithoutUserinfo` 同时供写判与读改写;查询参数判定作用于任意字符串)、`packages/spec/src/data/datasource-credential-redaction.ts`(`redactUrlPassword` 按写入侧同一区间改写)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/integration/connector-entry-url-credentials.test.ts src/data/datasource-credential-redaction.test.ts`,预期全部通过。 - 需要你做的:无,席位落地。 ## What changes One value-level rule at both doors — **a URL is judged after trimming surrounding whitespace; a credential query parameter is refused in any string of the entry.** - **Cleaned as a URL parser cleans it.** A value is judged after trimming its leading and trailing C0 control characters and spaces and removing every tab, newline and carriage return in it — both halves, both doors. Each query pair is cleaned before its name is read. The userinfo is read on the cleaned string and rewritten at the matching offsets of the authored string. - **One userinfo grammar at both doors.** The connector entry locates a userinfo with `urlUserinfoSpan`, the scheme-optional grammar the datasource doors already use: an authority starts at `scheme://` or a scheme-relative `//`. - **Any string.** The connector entry's credential query parameter rule runs the single value-level parse the datasource doors already run (`urlCredentialQueryParams`) on every string under `providerConfig` and `auth`, scheme or none. Same names, same located refusal, never echoing the value. - **The read rewrites exactly what the write refuses.** `packages/spec/src/data/driver/url-boundary.ts` (internal, not re-exported) holds the one boundary parse: `urlTrimmedBounds` and `urlUserinfoSpan`, offsets into the authored string. The datasource write accessors (`urlUserinfoPassword`, `urlUserinfoUsername`) read the userinfo there and the datasource read (`redactUrlPassword`) rewrites the password there. On the connector side `connectorUrlWithoutUserinfo` is both the write door's userinfo judgement (does it change the value?) and the read's rewrite, and the query pairs go through the shared `credentialQueryParamOf`. Every other character, the surrounding ones included, is served as stored. At this head the boundary parse is `urlAsParsed` (the cleaned string with a map back to raw offsets) and `urlUserinfoSpan` (raw offsets, the cleaned userinfo, the mapped colon); `connectorUrlWithoutUserinfo` removes the whole userinfo and its `@` at those offsets. - **Unchanged.** No rule keyed on a provider key name; `ConnectorSchema` keeps its posture; header and environment maps are judged by neither door; stored rows are never re-judged, a save of the served form keeps the stored value, and an edit stores the edit. No public export added or removed (`check:api-surface` unchanged). - ADR-0087 D3 entry `18.url-credential-judged-trimmed-and-in-any-connector-string.ts`; changeset `@objectstack/spec` minor, BREAKING, with its FROM → TO and the registration marker. ## Tests - `packages/spec/src/integration/connector-entry-url-credentials.test.ts` — new block: refused at the string (userinfo behind surrounding whitespace, `api_key`/`client_secret` in a URL behind whitespace, `token` and the instance's `paramName` in a string with no scheme, `signature` in a string that is not a URL, an `auth.tokenUrl` behind whitespace); CONTROL accepts (clean URLs with surrounding whitespace, scheme-less and non-URL strings holding none, empty credential values); the read rewrites the same position with surrounding bytes kept and the served form passes the write door; refused ⇔ rewritten over `providerConfig.baseUrl`, a non-URL `providerConfig` string and `auth.tokenUrl`. - `packages/spec/src/integration/connector-credential-redaction.test.ts` — the pin that held a non-URL string with a credential parameter untouched pinned the removed boundary; it is split into a still-untouched CONTROL (a non-URL string holding none) and the new rewrite pin. - `packages/spec/src/data/driver/driver-credential-refusal.test.ts` — per URL-bearing driver key (postgres, mysql, mongo, turso `url`, turso `syncUrl`): `user:password@` refused behind each of five surroundings, and a bare username behind each not refused for a password; `urlUserinfoPassword` judges the trimmed string. - `packages/spec/src/data/datasource-credential-redaction.test.ts` — the datasource read rewrites the password at the position the write door judges, keeps the username and every surrounding character, and the served form passes the write door; CONTROL strings come back byte for byte; `redactDatasourceConfig` names `url`. - Dogfood, both HTTP save doors: `connector-meta-door-url-and-nested-keys.dogfood.test.ts` gains a block on the existing showcase boot (five 422 refusals at `PUT /api/v1/meta/connector/:name` with nothing stored; a credential-free URL with surrounding whitespace saves, materializes `ready` and dispatches; a seeded row is served with the parts removed at the judged position to a member and an administrator; a save of the served form keeps the stored values). New `datasource-meta-door-url-credential-trimmed.dogfood.test.ts` (three 422 refusals at `PUT /api/v1/meta/datasource/:name` with nothing stored; a bare username and a clean URL save as written; a seeded row is served with the password removed and its surrounding characters kept; the stored row untouched). - Test names state the rule. Every placeholder is fake and every assertion on one is its absence from what a door served. - `connector-entry-url-credentials.test.ts` — a second block: refused at the string for a credential name holding a tab or newline (URL and non-URL string), a userinfo behind a scheme or `//` holding a tab or carriage return, a scheme-relative userinfo; CONTROL accepts for an ordinary name holding a tab, a credential value of only tab/newline characters, a cleaned scheme with no userinfo, a scheme-relative authority with no userinfo, an `@` in a scheme-relative path; the read rewrites at the matching authored offsets; refused ⇔ rewritten. - `driver-credential-refusal.test.ts` — per URL-bearing driver key: `user:password@` refused with a tab, newline or carriage return inside the scheme or the password; CONTROL a bare username and a password of only that character; per query driver: the parameter refused with such a character inside its name, CONTROL a value of only such characters; the shared parses read pairs and userinfo with them deleted. - `datasource-credential-redaction.test.ts` — the read removes the password at the mapped offsets and a credential pair whose name holds such a character, keeping the rest of the query; CONTROL strings unchanged. - Dogfood, both save doors: connector — 422 for a credential name holding a tab, a scheme holding a tab, a scheme-relative userinfo; CONTROL an ordinary name holding a tab saves, materializes and is sent as the parser reads it, a scheme-relative URL with no userinfo saves. Datasource postgres — 422 for a `password` name holding a tab and a scheme holding a tab; CONTROL a bare username and an ordinary name each with a tab save as written; a seeded row is served with its credential parts removed at the judged positions. ## Acceptance notes - **Basis for the two additions beyond the ruled text.** The maintainer's ruling D is "one value-level rule at both doors"; its stated purpose is that the check sees what the consuming parser sees. Removing tab/newline characters before judging, and the connector using the datasource's scheme-optional userinfo grammar, are that same rule. They narrow beyond the ruled literal text, and the seat has put that to the maintainer as a veto window. - **Consumers.** The cleaning matches the WHATWG parse that pg-connection-string, mysql2 and the mongodb driver apply to these values; for `@libsql/core`, which applies none, it only refuses at publish a value the client rejects at connect. - **Cost measured.** A non-URL string under `providerConfig` holding a credential parameter with a value is now refused. Over `examples/`, `packages/apps/`, `skills/`, `content/docs/` and `apps/docs/` (`git grep` for a credential name after `?` or `&` with a value): 3 hits, none in a connector entry (a storage-service doc comment and two auth-docs `fetch` calls); the three OpenAPI documents `packages/apps` and the showcase reference by path: 0 hits. - `auth` endpoints are URLs by their own declared format, so a string with no scheme there is already refused by that format; the any-string rule matters under `providerConfig`. - The other package this diff touches is `packages/qa/dogfood` (private, tests only). ## Verification Head `49b7049edb` (`origin/main` at `9edd6e783` merged in). - Local gate subset: `dispatch-gates --commands` derived 81 commands, each exit captured before any pipe; `--ran`: 81 derived, 81 run, 0 NOT-MEASURED. `check:type-check-debt` exit 0 (0 raw tsc errors). `check:api-surface`: `public API surface + factory signatures unchanged`. `check:authorable-surface`, `check:strictness-ledger`, `check:spec-changes`, `check:upgrade-guide`, `check:dual-build-cjs-loads` exit 0. `check:generated`: `All 14 generated artifacts are up to date`. - Typecheck: spec and dogfood exit 0. Four touched spec files 463/463. Full `pnpm --filter @objectstack/spec test`: 807 files, 24224 passed, 1 todo, exit 0. Two dogfood pin files 51/51. Full `pnpm --filter @objectstack/dogfood test`: 410 passed and 3 skipped files, 3400 passed and 28 skipped tests, exit 0. - Ablation, one shot per leg through `scripts/ablation-replace.mjs`, each restored to blob == HEAD with `git diff HEAD` empty: round 1 — leg A (no trim) 34/395 red, leg B (scheme gate on the query half) 6/395 red; round 2 — leg C (tab/newline removal removed) 51/463 red, split into query half only 13 and userinfo half only 38; leg D (scheme-optional authority removed) 9/463 red. - CI on `49b7049edb`: 55 success, 9 skipped, 0 failed. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 790d9cf commit cfd237f

13 files changed

Lines changed: 953 additions & 82 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a URL is judged after trimming surrounding whitespace and removing tab and newline characters, as a URL parser does; a credential query parameter is refused in any string of a connector entry
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered url-credential-judged-trimmed-and-in-any-connector-string -->
11+
12+
**BREAKING** accept-set narrowing at the two metadata doors that judge a URL credential: an authored connector instance (`DeclarativeConnectorEntrySchema` — `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load, `PUT /api/v1/meta/connector/:name`) and a datasource driver config's URL keys (`config.url` on postgres, mysql, mongo and turso, turso `config.syncUrl`) at every door that parses the datasource.
13+
14+
- **One value-level rule at both doors.** A value is judged as a URL parser reads it: after its leading and trailing C0 control characters and spaces are trimmed and every tab, newline and carriage return in it is removed. A connector URL holding a userinfo, and a datasource URL holding a userinfo password, are refused as the parser reads them; a bare username in a datasource URL stays accepted. The same cleaning applies to each query pair before its name is read, at both doors.
15+
- **One userinfo grammar at both doors.** The connector entry locates a userinfo with the grammar the datasource doors already use: an authority starts at `scheme://` or at a scheme-relative `//`.
16+
- **A credential query parameter is refused in any string of a connector entry.** The connector query rule is the single value-level parse the datasource doors already run, applied to every string under `providerConfig` and `auth` — whether or not it starts with a scheme. Same names as before (`api_key`, `token`, `access_token`, `key`, `secret`, `signature` and the rest of the connector list, matched case-insensitively on the decoded name, plus the instance's own `auth.paramName`), same located refusal (`422 INVALID_METADATA` at the save door, one issue per string, naming the parts, never the value).
17+
- **Read.** The connector read redactor and the datasource read redactor rewrite exactly what the write refuses, at the position the write judged, and serve every other character as stored. The datasource read locates a userinfo password by the write door's own boundary parse.
18+
- **Unchanged.** `ConnectorSchema` (a plugin's `registerConnector` def) keeps its posture; a header or environment map under `providerConfig` is still judged by neither door; a stored row is never re-judged: a save of its served form keeps the stored value, and an edit of the served value stores exactly the edit.
19+
20+
## FROM → TO
21+
22+
```ts
23+
// before — parsed green; now refused at providerConfig.note
24+
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
25+
providerConfig: { baseUrl: 'https://api.billing.example.com/v1', note: 'sandbox: append ?api_key=… to try it' },
26+
auth: { type: 'none' } },
27+
28+
// after — the note holds no credential, and the key is a reference
29+
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
30+
providerConfig: { baseUrl: 'https://api.billing.example.com/v1', note: 'sandbox: the key is BILLING_API_KEY' },
31+
auth: { type: 'api-key', credentialRef: 'BILLING_API_KEY', paramName: 'api_key' } },
32+
```
33+
34+
For a datasource, keep the URL credential-free (`mysql://app@db.internal:3306/app`) and bind the password through the Setup → Datasources connection form's secret field, or reference it with `external.credentialsRef`. There is deliberately no automatic rewrite: the value in the string may be the only copy of the secret. The upgrade guide entry is `url-credential-judged-trimmed-and-in-any-connector-string`.

‎packages/qa/dogfood/test/connector-meta-door-url-and-nested-keys.dogfood.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
// same row is kept (CONTROL). A `retryConfig` the author leaves out is
2929
// removed with its stored keys, as before (CONTROL), and one stored as a
3030
// non-object is still kept at rest by a save that leaves it out (CONTROL).
31+
// - ONE VALUE-LEVEL RULE: a URL is judged after trimming its leading and
32+
// trailing C0 control characters and spaces, where a URL parser reads it,
33+
// and a credential query parameter is refused in any string under
34+
// `providerConfig` and `auth`, scheme or none. The read rewrites exactly
35+
// that, at the position the write door judged, and a save of the served
36+
// form keeps the stored values; a credential-free URL with surrounding
37+
// whitespace saves and dispatches (CONTROL).
3138
//
3239
// Every credential below is an obviously-fake placeholder, and every assertion
3340
// on one is its absence from what a door served or stored, or its presence at
@@ -412,4 +419,111 @@ describe('connector metadata doors: an authored connector keeps no credential in
412419
expect(atRest.retryConfig).toBe(PLACEHOLDERS.nested);
413420
});
414421
});
422+
423+
describe('one value-level rule: a URL is judged after trimming surrounding whitespace; a credential query parameter is refused in any string', () => {
424+
const TAB = String.fromCharCode(0x09);
425+
const LF = String.fromCharCode(0x0a);
426+
const TRIM_ROW = 'cun_trim_stored';
427+
const TRIM_CONTROL = 'cun_trim_control';
428+
const storedTokenUrl = () => ` https://cun:${PLACEHOLDERS.authEndpoint}@auth.vendor.example.com/token${LF}`;
429+
const storedNote = () => `sandbox ?region=eu&token=${PLACEHOLDERS.urlQuery}`;
430+
431+
beforeAll(async () => {
432+
// A row stored before the refusal: a clean save, then the system context
433+
// writes the credential parts over it.
434+
const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${TRIM_ROW}`, oauth2(TRIM_ROW));
435+
expect(res.status, await res.clone().text()).toBe(200);
436+
const [row] = await stored(TRIM_ROW);
437+
const seeded = {
438+
...oauth2(TRIM_ROW, { tokenUrl: storedTokenUrl() }),
439+
providerConfig: { baseUrl: `${upstreamUrl}/v1`, note: storedNote() },
440+
};
441+
await ql.update('sys_metadata', { id: row.id, metadata: JSON.stringify(seeded) }, { context: SYSTEM });
442+
});
443+
444+
describe('WRITE: refused at the string, and nothing is stored', () => {
445+
const cases: Array<[label: string, name: string, body: () => Record<string, unknown>, at: string]> = [
446+
['a userinfo password in `providerConfig.baseUrl` behind a leading space', 'cun_t_userinfo',
447+
() => instance('cun_t_userinfo', { providerConfig: { baseUrl: ` https://u:${PLACEHOLDERS.urlPassword}@api.vendor.example.com/v1` } }), 'providerConfig.baseUrl'],
448+
['`api_key` in the `providerConfig.baseUrl` query behind a leading tab', 'cun_t_api_key',
449+
() => instance('cun_t_api_key', { providerConfig: { baseUrl: `${TAB}${upstreamUrl}/v1?api_key=${PLACEHOLDERS.urlQuery}` } }), 'providerConfig.baseUrl'],
450+
['`token` in the query of a `providerConfig.baseUrl` with no scheme', 'cun_t_no_scheme',
451+
() => instance('cun_t_no_scheme', { providerConfig: { baseUrl: `api.vendor.example.com/v1?token=${PLACEHOLDERS.urlQuery}` } }), 'providerConfig.baseUrl'],
452+
['a credential query parameter in a `providerConfig` string that is not a URL', 'cun_t_note',
453+
() => instance('cun_t_note', { providerConfig: { baseUrl: `${upstreamUrl}/v1`, note: `sandbox ?token=${PLACEHOLDERS.urlQuery}` } }), 'providerConfig.note'],
454+
['a userinfo in `auth.tokenUrl` behind a leading space', 'cun_t_token_url',
455+
() => oauth2('cun_t_token_url', { tokenUrl: ` https://c:${PLACEHOLDERS.authEndpoint}@auth.vendor.example.com/token` }), 'auth.tokenUrl'],
456+
['`api_key` in the `providerConfig.baseUrl` query, a tab inside its name', 'cun_t_key_tab',
457+
() => instance('cun_t_key_tab', { providerConfig: { baseUrl: `${upstreamUrl}/v1?api${TAB}_key=${PLACEHOLDERS.urlQuery}` } }), 'providerConfig.baseUrl'],
458+
['a userinfo in `providerConfig.baseUrl`, a tab inside its scheme', 'cun_t_scheme_tab',
459+
() => instance('cun_t_scheme_tab', { providerConfig: { baseUrl: `ht${TAB}tps://u:${PLACEHOLDERS.urlPassword}@api.vendor.example.com/v1` } }), 'providerConfig.baseUrl'],
460+
['a userinfo in a scheme-relative `providerConfig.baseUrl`', 'cun_t_scheme_relative',
461+
() => instance('cun_t_scheme_relative', { providerConfig: { baseUrl: `//u:${PLACEHOLDERS.urlPassword}@api.vendor.example.com/v1` } }), 'providerConfig.baseUrl'],
462+
];
463+
464+
it.each(cases)('%s', async (_label, name, body, at) => {
465+
await expectRefused(name, body(), at);
466+
expect(await stored(name), `${name}: nothing stored`).toEqual([]);
467+
});
468+
});
469+
470+
it('CONTROL: a credential-free URL behind a leading space, and a string that is not a URL holding none, save; the instance materializes ready and dispatches', async () => {
471+
const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${TRIM_CONTROL}`, instance(TRIM_CONTROL, {
472+
providerConfig: { baseUrl: ` ${upstreamUrl}/v1?region=eu`, note: 'sandbox ?region=eu&token=' },
473+
}));
474+
expect(put.status, await put.clone().text()).toBe(200);
475+
await waitReady(TRIM_CONTROL);
476+
const handler = automation.resolveConnectorAction(TRIM_CONTROL, 'request');
477+
expect(handler, `${TRIM_CONTROL} is materialized with its request action`).toBeTypeOf('function');
478+
const before = received.length;
479+
const out = await handler({ method: 'GET', path: '/probe' }, {});
480+
expect(out?.status, JSON.stringify(out)).toBe(200);
481+
expect(received.length).toBe(before + 1);
482+
expect(received[received.length - 1]).toBe('/v1/probe?region=eu');
483+
});
484+
485+
it('CONTROL: an ordinary query parameter with a tab inside its name saves and dispatches as a URL parser reads it; a scheme-relative URL with no userinfo saves', async () => {
486+
const name = 'cun_trim_tab_control';
487+
const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${name}`, instance(name, {
488+
providerConfig: { baseUrl: `${upstreamUrl}/v1?reg${TAB}ion=eu` },
489+
}));
490+
expect(put.status, await put.clone().text()).toBe(200);
491+
await waitReady(name);
492+
const handler = automation.resolveConnectorAction(name, 'request');
493+
expect(handler, `${name} is materialized with its request action`).toBeTypeOf('function');
494+
const before = received.length;
495+
const out = await handler({ method: 'GET', path: '/probe' }, {});
496+
expect(out?.status, JSON.stringify(out)).toBe(200);
497+
expect(received.length).toBe(before + 1);
498+
expect(received[received.length - 1]).toBe('/v1/probe?region=eu');
499+
// Saved disabled: the pin is the door's answer, not a materialization.
500+
const relative = await stack.apiAs(admin, 'PUT', '/meta/connector/cun_trim_relative_control', instance('cun_trim_relative_control', {
501+
enabled: false,
502+
providerConfig: { baseUrl: '//api.vendor.example.com/v1' },
503+
}));
504+
expect(relative.status, await relative.clone().text()).toBe(200);
505+
});
506+
507+
it('READ: the stored row is served with each credential part removed at the position the write door judged, every other character as stored', async () => {
508+
for (const token of [member, admin]) {
509+
const { text, item } = await read(token, TRIM_ROW);
510+
expect(holdsAPlaceholder(text)).toBe(false);
511+
expect(item.auth.tokenUrl).toBe(` https://auth.vendor.example.com/token${LF}`);
512+
expect(item.providerConfig.note).toBe('sandbox ?region=eu');
513+
}
514+
const atRest = bodyOf((await stored(TRIM_ROW))[0]);
515+
expect(atRest.auth.tokenUrl, 'the stored row is never redacted').toBe(storedTokenUrl());
516+
expect(atRest.providerConfig.note).toBe(storedNote());
517+
});
518+
519+
it('CARRY-FORWARD: a save of the served form keeps each stored value', async () => {
520+
const served = authorable((await read(admin, TRIM_ROW)).item);
521+
const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${TRIM_ROW}`, { ...served, label: 'CUN trim (edited)' });
522+
expect(res.status, await res.clone().text()).toBe(200);
523+
const atRest = bodyOf((await stored(TRIM_ROW))[0]);
524+
expect(atRest.label).toBe('CUN trim (edited)');
525+
expect(atRest.auth.tokenUrl).toBe(storedTokenUrl());
526+
expect(atRest.providerConfig.note).toBe(storedNote());
527+
});
528+
});
415529
});

0 commit comments

Comments
 (0)